Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/images/render_example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ htmlcov/
scripts/
*.csv
*.png
# ...except checked-in documentation assets (e.g. PR/README screenshots)
!/.github/images/*.png

# downloaded render textures (see src/found_tools/render/README.md)
textures/

# Debugging files
.vscode/
Expand Down
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.14
3.13
106 changes: 106 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# found-tools

Python tools (`uv` + `just`) for testing [found](https://github.com/UWCubeSat/found).

## Before pushing or opening a PR

**Run `just check-all` locally and make sure it is fully green before pushing
or opening a PR.** CI enforces a subset of the same checks (see below) and
will fail the same way `just check-all` does locally -- there is no reason to
push and wait for CI to tell you what you could have caught locally.

```bash
just check-all
```

If it doesn't pass locally, don't push.

## What `just check-all` actually runs

`check-all` is `lint`, `cov`, then `typing` (see `justfile`), in that order:

- **`just lint`** -- `uvx ruff check` then `uvx ruff format` (the local
formatter, no `--check`, so it will silently reformat files in place; see
the CI mismatch note below).
- **`just cov`** -- `uv run coverage erase`, then
`uv run coverage run -m pytest tests`, `coverage html`, then
`coverage report --fail-under=100`. **Coverage must be 100%** (see
`[tool.coverage.report] fail_under = 100` in `pyproject.toml`). Lines that
genuinely can't be covered (CLI entry points, `bpy`-only code) are marked
`# pragma: no cover` or excluded via `[tool.coverage.run] omit`, not left
uncovered.
- **`just typing`** -- `uvx ty check --python .venv src` (Astral's `ty` type
checker). **This step does not run in CI** (see below) -- it's local-only,
so don't skip it just because CI is green.

Run any single piece directly when iterating, e.g. `just test`,
`just lint`, `just typing`.

## What GitHub Actions actually runs

Two workflows under `.github/workflows/`:

- **`ci.yml`** (`CI`) -- runs on every push to `main` and every PR into
`main`. On `ubuntu-latest`, it:
1. `uv tool install ruff@latest`
2. `uv python install` (reads `.python-version`)
3. `uv sync --locked --all-extras --dev` -- **`--locked` means CI fails
outright if `uv.lock` is stale relative to `pyproject.toml`.** Run
`uv lock` (or just `uv sync`) locally after touching dependencies and
commit the updated `uv.lock`.
4. `uv run ruff check` and `uv run ruff format --check`
5. `uv run coverage erase`, `coverage run -m pytest tests`,
`coverage html`, `coverage report --fail-under=100`
6. Uploads the `htmlcov/` coverage report as a build artifact
- **CI does not run `ty check`.** A branch can be green on GitHub Actions
with type errors that only `just typing` / `just check-all` would catch.
- **CI does not install Blender or run a real render smoke test** beyond
whatever `pytest` covers -- the `bpy`-based tests in
`tests/found_tools/render/test_blender_scene.py` run for real (no
Blender is required separately; `bpy` is a project dependency, pinned
in `uv.lock`), but nothing in CI actually opens the rendered PNG.
- **`publish.yml`** (`Publish`) -- runs on a successful `CI` run whose
triggering push was a `v*` tag, or directly on `push` of a `v*` tag.
Builds with `uv build`, smoke-tests the wheel and sdist, then
`uv publish`s to PyPI. Not relevant to day-to-day PRs; only matters when
cutting a release per the README's tagging instructions.

## Known CI/local mismatches (things that pass locally but fail in CI, or vice versa)

- **`ruff` version drift.** CI always installs `ruff@latest` fresh
(`uv tool install ruff@latest`), so its rule set can be newer than
whatever `uvx ruff` resolves to locally out of a stale `uvx` cache. This
has already caused a real CI failure (25 lint errors -- `DTZ001`, `UP017`,
`C408`, import sorting -- that a locally-cached older `ruff` didn't catch).
Before pushing, force the same "always fetch latest" behavior CI uses:
```bash
uvx ruff@latest check
uvx ruff@latest format --check
```
(`just lint` uses a bare `uvx ruff`, which can silently use a cached
version -- don't rely on it alone to match CI.)
- **`just lint` reformats; CI only checks.** `just lint`'s `uvx ruff format`
(no `--check`) will happily rewrite files to fix formatting. CI's
`ruff format --check` just fails if anything *would* be reformatted. Run
`just lint` (or `ruff format`) and let it fix things, then re-run and
commit before pushing -- don't assume a clean CI run just because
`just check-all` didn't error; it may have silently reformatted files out
from under you that still need to be `git add`ed.
- **`ty check` is local-only.** Don't treat a green GitHub Actions run as
proof the codebase type-checks; run `just typing` explicitly.
- **`--locked` sync.** If you add/change a dependency in `pyproject.toml`
and forget `uv lock`, tests can still pass locally (using your unlocked,
freshly-resolved environment) while CI's `uv sync --locked` fails
immediately, before any lint or test runs.

## Project structure

- `src/found_tools/<tool_name>/` -- one directory per CLI tool
(`calibrate/`, `edge/`, `render/`, `utils/`), each with its own
`main.py`/entry point registered under `[project.scripts]` in
`pyproject.toml`, and often a tool-specific `README.md`.
- `tests/found_tools/<tool_name>/` -- mirrors the `src/` layout.
- The `render` tool (`found_tools.render`) is the only place `bpy` (Blender
as a Python module) is imported; see `src/found_tools/render/README.md`
for how it's structured (`geometry.py`/`scene.py` are pure and fully
tested, `blender_scene.py` does the actual Blender scene construction).
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
[project]
name = "found-tools"
version = "0.0.0"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
requires-python = ">=3.13,<3.14"
dependencies = [
"bpy>=5.1.0",
"matplotlib>=3.10",
"numpy>=2.0",
"opencv-python>=4.13.0.92",
Expand All @@ -16,6 +17,7 @@ dependencies = [

[project.scripts]
found_tools_attitude = "found_tools.calibrate.main:main"
found_tools_render = "found_tools.render.main:main"

[dependency-groups]
dev = ["pytest", "coverage"]
Expand All @@ -41,3 +43,6 @@ exclude_lines = [
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"


[tool.ty.src]
exclude = ["src/found_tools/render/blender_scene.py"]
122 changes: 122 additions & 0 deletions src/found_tools/render/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Render

The `render` tool generates synthetic Earth test imagery for `EarthLim` and
similar attitude-determination tools in [found](https://github.com/UWCubeSat/found).
It computes Sun/camera geometry for a given date, position, and attitude,
then drives a headless Blender scene to produce a physically-plausible
rendered image: a WGS84-ellipsoid Earth with a cloud layer, a Sun lamp along
the computed Sun vector, and a black deep-space background.

The Earth mesh (and the concentric cloud shell) are built as WGS84
ellipsoids -- equatorial radius 6,378,137 m, polar radius 6,356,752.314245 m
(flattening ~1/298.257) -- not spheres. The ~21 km difference is small next
to Earth's radius but matters for accurate limb-fitting.

**Not yet implemented**: an atmosphere/limb-glow effect. An earlier version
of this tool had a shell-based glow effect, but it was removed for now --
see git history on this file/`blender_scene.py` if picking that back up.

## How it works

1. `found_tools.render.geometry` computes the Sun direction and the
ECI->ECEF rotation for the requested UTC date using closed-form
analytical formulas (no SPICE kernel or IERS data download required).
2. `found_tools.render.scene` packages the Sun vector, camera intrinsics
(reusing `found_tools.utils.Camera`), camera pose, and date-appropriate
texture filenames into a scene JSON file.
3. `found_tools.render.main` (the `found_tools_render` CLI) writes the
scene JSON and, if `--render` is passed, calls
`found_tools.render.blender_scene.render_scene` in process to build and
render the scene.
4. `found_tools.render.blender_scene` is the only module that imports
`bpy` (Blender as a Python module, a project dependency). It can also be
run standalone inside a full Blender install
(`blender --background --python blender_scene.py`) if you need Blender's
own bundled interpreter instead.

## Textures

This tool does not bundle texture assets. Download them yourself into a
directory and pass it via `--texture-dir`:

- **Earth color**: [NASA Blue Marble Next Generation](https://visibleearth.nasa.gov/collection/1484/blue-marble),
one mosaic per month (`world.<year><month>.3x5400x2700.jpg`). The tool
picks the file matching the requested date's month.
- **Clouds**: a generic cloud layer texture (e.g. the Blue Marble cloud
mosaic), saved as `cloud_combined_2048.jpg`.
**Caveat**: NASA does not publish a month-by-month cloud product, so the
same cloud texture is used regardless of date. It gives the render a
plausible cloud layer, but it will not match real weather for the
requested date.

## Usage

Compute geometry and inspect the scene JSON without needing Blender
installed:

```bash
found_tools_render \
--date 2026-03-20T12:00:00 \
--position 25000000 0 0 \
--attitude 180 0 0 \
--focal-length 0.008 \
--pixel-pitch 5e-6 \
--x-resolution 1920 \
--y-resolution 1080 \
--texture-dir ./textures \
--output render.png
```

### Rendering

`bpy` (Blender as a Python module) is a project dependency, so add
`--render` to actually build and render the scene -- no separate Blender
install is required:

```bash
found_tools_render \
--date 2026-03-20T12:00:00 \
--position 25000000 0 0 \
--attitude 180 0 0 \
--focal-length 0.008 \
--pixel-pitch 5e-6 \
--x-resolution 1920 \
--y-resolution 1080 \
--texture-dir ./textures \
--output render.png \
--render
```

### Choosing camera parameters

`--position`, `--focal-length`, `--pixel-pitch`, and `--x/y-resolution`
must be consistent with each other, or the render can come out looking
like an unrecognizable, blurry close-up instead of a visible Earth disk.
The camera's field of view is set by focal length and sensor size
(`resolution * pixel_pitch`), same as a real camera; if that FOV is much
narrower than the Earth's angular size at the requested `--position`
(`2 * asin(6378137 / distance_from_earth_center_m)`), the camera is
effectively a telephoto lens pointed at a patch of the surface a few
kilometers wide, magnified to fill the whole frame -- the "blurry mess"
you'd expect from zooming a real photo in far past its resolution. Widen
the lens (shorter `--focal-length` and/or larger `--pixel-pitch`), move
`--position` farther out, or both, until the FOV comfortably exceeds the
Earth's angular size at that distance. The example above (25,000 km
altitude, an 8 mm-equivalent lens) puts the full Earth disk in frame with
margin.

## Flags

- `--date`: UTC date/time to render, ISO 8601. Drives the Sun vector and
the Blue Marble texture month.
- `--position`: Camera position in the ECEF frame, meters.
- `--attitude`: Camera attitude (ECEF->camera) as RA/DE/ROLL degrees, in
the same convention as the `calibrate` tool.
- `--focal-length`, `--pixel-pitch`, `--x-resolution`, `--y-resolution`:
Camera intrinsics, matched to the real camera model under test.
- `--texture-dir`: Directory containing the downloaded textures.
- `--output`: Path to write the rendered PNG to.
- `--scene-file`: Where to write the intermediate scene JSON (defaults
next to `--output`).
- `--render`: Actually build and render the scene with `bpy`. Without it,
only the scene JSON is written.
Empty file.
Loading
Loading