# Image Saving

`save_image()` writes a numpy array directly to PNG or JPEG — no manual conversion needed. Useful for MuJoCo/PyBullet renders, RL observations, model predictions, or any HxW / HxWxC array.

Requires Pillow: `pip install Pillow`.

## Basic Usage

```python
import numpy as np
from ml_dash import Experiment

with Experiment("vision/training").run as experiment:
    pixels = renderer.render()  # numpy array from MuJoCo, OpenCV, etc.

    experiment.files("frames").save_image(pixels, to="frame_001.png")  # lossless
    experiment.files("frames").save_image(pixels, to="frame_001.jpg")  # smaller
```

`save()` auto-detects numpy arrays and dispatches to `save_image()`, so these are equivalent:

```python
experiment.files("images").save(pixels, to="frame.png")
experiment.files("images").save_image(pixels, to="frame.png")
```

Works with any numpy array — OpenCV frames (remember `cv2.cvtColor(..., COLOR_BGR2RGB)`), `np.array(PIL.Image)`, model outputs, etc.

## Array Types

- **uint8** — passed through directly. Shape `HxW` (grayscale), `HxWx3` (RGB), or `HxWx4` (RGBA).
- **float in `[0.0, 1.0]`** — multiplied by 255 and cast to uint8.
- **float in any other range** — normalized via `(value - min) / (max - min) * 255`.

```python
experiment.files("images").save_image(np.random.rand(480, 640, 3), to="norm.png")
experiment.files("images").save_image(np.random.rand(480, 640) * 1000, to="scaled.png")
```

## Format: PNG vs JPEG

| Aspect       | PNG               | JPEG                |
|--------------|-------------------|---------------------|
| Compression  | Lossless          | Lossy               |
| File Size    | Larger            | Smaller             |
| Transparency | Yes               | No                  |
| Quality      | Perfect           | Configurable        |
| Best For     | Graphics, text    | Photos, renders     |
| Speed        | Slower            | Faster              |

The extension picks the encoder: `.png`, `.jpg`, and `.jpeg` all work. JPEG drops the alpha channel (transparent pixels composited onto white) and applies optimization.

## Quality

JPEG only. Default is 95. Range 1–100.

```python
experiment.files("frames").save_image(pixels, to="frame.jpg", quality=85)
```

Rough guide: **95–100** near-lossless, **85–90** balanced (recommended default for sequences), **70–80** visible compression, **below 50** poor.

## API Reference

### `save_image(array, *, to, quality=95)`

Save a numpy array as an image file.

**Parameters**
- `array` (`numpy.ndarray`) — image array, shape `HxW` or `HxWxC`.
- `to` (`str`) — target filename with extension (`.png`, `.jpg`, `.jpeg`).
- `quality` (`int`, optional) — JPEG quality 1–100. Default 95. Ignored for PNG.

**Returns** — dict with file metadata, or a queued-status dict when the write is buffered (see [/buffering](/buffering.md)).

**Raises** — `ImportError` if Pillow is missing; `ValueError` for invalid `array` or missing `to`.

## Example: MuJoCo Renders

```python
import mujoco
import numpy as np
from ml_dash import Experiment

with Experiment("robotics/mujoco-renders").run as experiment:
    model = mujoco.MjModel.from_xml_string(xml_content)
    data = mujoco.MjData(model)
    renderer = mujoco.Renderer(model, height=480, width=640)

    for i in range(1000):
        mujoco.mj_step(model, data)

        if i % 10 == 0:
            renderer.update_scene(data)
            pixels = renderer.render()  # (480, 640, 3) uint8

            experiment.files("robot/frames").save_image(
                pixels,
                to=f"frame_{i:05d}.jpg",
                quality=85,
            )
```

Image writes are buffered for non-blocking uploads — see [/buffering](/buffering.md). To align frames with numeric data, share a step index across `save_image()` and `track()` calls — see [/tracks](/tracks.md).
