
A policy that works in daylight and fails at night has three separate problems: the camera driver, the optics, and a dataset with one lighting condition. Here is which fix applies to which.
What you need to know
- •A policy that works at 14:00 and fails at 22:00 is a data problem sitting on a camera problem. The camera problem is the one you fix in five minutes.
- •In the one controlled real-robot study that isolates it (Xie et al., 2023), new lighting cost about 8 points of success rate. A new camera position cost about 46. Lighting is mid-table, not the worst factor.
- •Photometric augmentation is one flag, and it is off by default in LeRobot. It covers the easy half: global sensor-level shifts.
- •It does not cover the hard half: shadows that move with the arm, highlights on the gripper, and an auto-exposure loop rewriting the image mid-episode. The rest is data. Every published scaling result says the same thing: more environments beats more episodes in one environment.
Why a policy stops working at night
You record 60 episodes in the afternoon. Daylight through a window, two overhead LEDs, a cube on a mat. You fine-tune a GR00T N1.7 or SmolVLA checkpoint and it works. That evening the sun is gone, and the same checkpoint hesitates over the cube, closes the gripper on air, or drifts two centimetres and stalls. Nothing about the robot changed, and nothing about the policy weights changed. Only the photons changed.
This is the most-reported entry on policy only works in one setup, and three things happen at once. Optical: shadow direction flips, colour temperature drops from about 6500 K daylight to 2700-4000 K indoor. Electronic: the camera driver reacts and hands the network an image it never saw during fine-tuning. Statistical: the network learned 'the mat is this shade of grey' as a stand-in for 'the mat'. Only the electronic one is free to fix.
What actually changes in the image
Turning the lights down is not one transform. It is at least four separate changes to the tensor reaching the vision encoder, and only two of them resemble a brightness slider.
| Change in the room | What the sensor does | What the tensor looks like |
|---|---|---|
| Less total light | Auto-exposure lengthens integration time, then raises gain | Darker, noisier, motion-blurred: a long exposure smears a moving gripper |
| Different lamp type | Auto-white-balance re-estimates the illuminant | A global colour cast along the blue-to-orange axis |
| One lamp instead of diffuse daylight | Nothing. Pure optics | Hard-edged shadows that move with the arm, blown-out spots on plastic and metal |
| LED or fluorescent lamps on 50 or 60 Hz mains | Rolling shutter samples rows across the flicker cycle | Horizontal banding that drifts frame to frame |
| Dimmer scene, same USB bandwidth | More noise, so MJPEG compresses worse | Heavier artefacts, dropped frames on a loaded hub |
The camera fights you before the policy does
V4L2 on Linux exposes the relevant controls directly. V4L2_CID_EXPOSURE_ABSOLUTE is in 100 microsecond units, so 1 means 1/10000 s. V4L2_CID_WHITE_BALANCE_TEMPERATURE is in Kelvin, and the kernel docs say a driver should cover at least 2800 (incandescent) to 6500 (daylight). V4L2_CID_POWER_LINE_FREQUENCY takes DISABLED (0), 50HZ (1), 60HZ (2) or AUTO (3), and exists to suppress flicker.
# what does this camera actually expose?
v4l2-ctl --device=/dev/video0 --list-ctrls-menus
# typical uvcvideo names on a recent kernel
# auto_exposure: 1 = manual, 3 = aperture priority (auto)
v4l2-ctl -d /dev/video0 -c auto_exposure=1
v4l2-ctl -d /dev/video0 -c exposure_time_absolute=250 # 25 ms
v4l2-ctl -d /dev/video0 -c white_balance_automatic=0
v4l2-ctl -d /dev/video0 -c white_balance_temperature=4600
v4l2-ctl -d /dev/video0 -c power_line_frequency=1 # 50 Hz mains
# confirm the writes stuck; some drivers ignore them mid-stream
v4l2-ctl -d /dev/video0 --list-ctrls | grep -E 'exposure|white_balance|power_line'Auto-exposure is a second, untrained control loop between the room and your network, and it reacts to the arm entering the frame. Record with it on and the approach frames are exposed for a bright scene; run at night and the controller compensates while the policy is deciding, so the observation distribution becomes non-stationary in a way that appears in none of your training episodes. Lock exposure and white balance for recording and inference, same values for both. On macOS and Windows those UVC controls sit behind different APIs, and plenty of webcams ignore a manual exposure request: verify by pointing a lamp at the lens. If the device disappears after you write controls, see camera not detected.
What the published ablations actually say
There is more folklore than data here, so it is worth naming the studies. The most useful is Decomposing the Generalization Gap in Imitation Learning for Visual Robotic Manipulation (Xie, Lee, Xiao and Finn, July 2023). They varied one environment factor at a time on language-conditioned imitation learning policies, in simulation and on a real robot, and released the benchmark (Factor World: 19 tasks, 11 factors, Apache-2.0). Their conclusion: new backgrounds, distractors and lighting are the easier factors, new table textures and camera positions the harder ones. The real-robot numbers behind that ordering, read from Figure 6 for the no-augmentation policy:
| Shifted factor | Real-robot success | Drop |
|---|---|---|
| None (original setup) | 91.7% | - |
| New background | 88.9% | 2.8 points |
| New lighting | 83.3% | 8.4 points |
| New distractor objects | 80.6% | 11.1 points |
| New table texture | 52.8% | 38.9 points |
| New camera position | 45.8% | 45.9 points |
Two things matter more than the numbers. Lighting is mid-table: painful, survivable, and far smaller than moving the camera, so if your policy also degrades when you bump the tripod, fix the tripod first. And the paper reports that most pairs of factors do not compound: new table texture plus new background scored about the same as new table texture alone. A night failure is rarely one clean factor, but it is also rarely a multiplicative disaster.
RT-1 (Brohan et al., December 2022) is the second data point. Its L1 tier is defined as a new counter-top layout and new lighting, bundled; L2 adds unseen distractors, L3 drastically new settings. That the authors did not isolate lighting is itself informative. On the separate robustness slices RT-1 held 83% with distractors and 59% against changed backgrounds, against 47% and 41% for BC-Z, on roughly 130k episodes over 700-plus tasks, 13 robots, 17 months. BC-Z shows the mechanism more cleanly: its protocol deliberately included background variation from recording in multiple locations, and it reached 44% on held-out tasks with no robot demonstrations at all. Diversity was engineered in, not bolted on as a transform.

What augmentation fixes
Photometric augmentation simulates the sensor-level part of a lighting change: a global brightness shift, a colour cast, a contrast change, a gamma curve. It is applied on the fly at training time and costs nothing at inference. In LeRobot it is also off unless you switch it on.
In src/lerobot/transforms/transforms.py on main (read 2026-08-24), ImageTransformsConfig.enable defaults to False. If you have never passed --dataset.image_transforms.enable=true, no training run of yours has had a single pixel augmented. Cheapest experiment on the list, and most people have not run it.
The transform registry in that file is larger than the documentation page suggests. The docs list ColorJitter, SharpnessJitter and Identity; the source registers nine custom transforms, several written specifically for lighting. Defaults below are read from main on 2026-08-24, so check them against the version you have installed.
| Transform | Default range | What it simulates |
|---|---|---|
| ColorJitter brightness / contrast | (0.8, 1.2) each | Global light level, dynamic range |
| ColorJitter saturation / hue | (0.5, 1.5) and (-0.05, 0.05) | Sensor colour processing, small white-balance error |
| SharpnessJitter | (0.5, 1.5) | Focus and in-camera sharpening |
| GammaCorrection | gamma (0.5, 2.0), log-symmetric sampling | Auto-exposure settings, sensor response curves |
| PlanckianJitter | temperature (3000, 15000) K | Colour-temperature shift along the black-body locus |
| GaussianPatchBrightness | 1 to 4 patches, sigma (0.05, 0.25), factor (0.4, 1.6) | Uneven overhead lighting, spotlights, shadow patches |
| RandomShadow | opacity (0.3, 0.6), vertical band | Cast shadows from objects or people nearby |
| MotionBlur, GaussianNoise, JPEGCompression, CoarseDropout | see source | Long exposure, high gain, codec artefacts, occlusion |
PlanckianJitter is the interesting one. Rather than jittering hue arbitrarily it samples a black-body temperature between 3000 K and 15000 K and applies the matching red and blue channel scaling while preserving green, following Zini et al. (2022). That output actually resembles swapping a daylight lamp for a tungsten one. Generic hue jitter produces colours no real illuminant can, which is that paper's argument against plain colour jitter.
IMAGE_TRANSFORMS='{
"brightness": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"brightness": [0.7, 1.3]}},
"contrast": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"contrast": [0.6, 1.4]}},
"gamma": {"weight": 1.0, "type": "GammaCorrection", "kwargs": {"gamma": [0.5, 2.0]}},
"planckian": {"weight": 1.0, "type": "PlanckianJitter", "kwargs": {"temperature": [3000, 9000]}},
"patches": {"weight": 1.0, "type": "GaussianPatchBrightness", "kwargs": {}},
"shadow": {"weight": 1.0, "type": "RandomShadow", "kwargs": {"opacity": [0.2, 0.5]}}
}'
lerobot-train \
--dataset.repo_id=$HF_USER/$DATASET_NAME \
--dataset.image_transforms.enable=true \
--dataset.image_transforms.max_num_transforms=3 \
--dataset.image_transforms.tfs="$IMAGE_TRANSFORMS" \
--policy.type=smolvla --policy.device=cuda \
--batch_size=2 --steps=20000 --seed=1000 \
--output_dir=outputs/train/$DATASET_NAME
# look at the frames before you spend GPU hours on them
lerobot-imgtransform-viz \
--repo-id=$HF_USER/$DATASET_NAME \
--output-dir=./transform_examples --n-examples=5The same knob on the GR00T side
Isaac-GR00T has its own fine-tuning entry point rather than going through lerobot-train, and exposes colour jitter as a tyro CLI flag. The SO-100 walkthrough in getting_started/finetune_new_embodiment.md passes it explicitly.
- 1Convert the dataset if it is v3.0
GR00T reads a flavour of LeRobot v2 with an extra
meta/modality.json, so a v3.0 dataset will not load. The repo ships the converter in its own environment. See dataset rejected: v3 if the loader complains.bashcd scripts/lerobot_conversion uv venv && source .venv/bin/activate uv pip install -e . --verbose python convert_v3_to_v2.py --repo-id <DATASET_REPO_ID> - 2Fine-tune with explicit colour jitter
The flag takes alternating key-value pairs, not JSON. These values come from the repository's SO-100 example. Omitting the flag is not the same as disabling augmentation: the field defaults to None, which the docstring says falls back to the colour jitter baked into the pretrained model config.
bashCUDA_VISIBLE_DEVICES=0 uv run python \ gr00t/experiment/launch_finetune.py \ --base-model-path nvidia/GR00T-N1.7-3B \ --dataset-path ./demo_data/cube_to_bowl_5 \ --embodiment-tag NEW_EMBODIMENT \ --modality-config-path examples/SO100/so100_config.py \ --num-gpus 1 --output-dir /tmp/so100 \ --max-steps 2000 --global-batch-size 32 \ --color-jitter-params brightness 0.3 contrast 0.4 saturation 0.5 hue 0.08 \ --dataloader-num-workers 4 - 3Decide whether to unfreeze the vision tower
In
gr00t/configs/finetune_config.py,tune_visualandtune_llmboth default toFalse; onlytune_projectorandtune_diffusion_modelare on. Colour jitter therefore regularises the projector and action head, not the encoder. Unfreezing it is a real option and a real way to overfit 50 episodes.bash--tune-visual True # adds the visual encoder to the trainable set - 4Or suppress the background instead of jittering it
The repo carries a mask-guided background suppression example that applies noise or tint only to masked regions, described as useful for sim-to-real transfer or for stopping the model overfitting to static backgrounds. It needs per-frame masks, which is real extra work.
bash--extra_augmentation_config '{"background_noise_transforms": [{"target_mask_values": [0], "p": 1.0}]}'
The Isaac-GR00T README states that users may observe 5 to 6 percent variance between runs due to non-deterministic image augmentations, and FinetuneConfig has no seed field to pin them with. If you compare a run with colour jitter against one without and the gap is under about six points, you have measured noise. LeRobot defaults to seed 1000 and accepts --seed.
- Free at inference. No latency cost, no extra parameters.
- Covers exposure, gain and white-balance drift, a large share of real night failures.
- One flag on a dataset you already have. No re-recording, no new hardware.
- GammaCorrection and PlanckianJitter are physically motivated, so augmented frames stay inside what a camera can actually produce.
- It is global. It cannot create a cast shadow that tracks the arm, or a highlight that appears at one joint angle.
- It cannot change what is behind the workspace, so it does nothing for a new room.
- Too aggressive and it hurts. The LeRobot docs warn that strong augmentations degrade performance and too many at once destabilise training.
- In the Xie et al. real-robot results, photometric distortion helped texture factors more than lighting itself, and it cannot fix a camera that has moved. Random crop is what addresses that.
What augmentation does not fix
The honest boundary is global against spatial. Photometric transforms apply one function to every pixel, or in the case of GaussianPatchBrightness and RandomShadow, one crude mask unrelated to your actual scene geometry. Real lighting changes are geometric. What stays broken after every colour knob is turned:
- Cast shadows tied to the arm. Under one lamp the gripper throws a hard shadow onto the target that moves with joint angles and reads as a second object. No global transform generates it.
- Specular highlights. A shiny jaw or laminated table produces a blown-out patch whose position depends on lamp, surface and pose. Brightness jitter dims the frame, it does not add a highlight.
- Motion blur correlated with speed. A long exposure blurs fast joints more than slow ones, so blur carries action information;
MotionBlursamples independently of the action, the wrong correlation. - A different background. That is what GreenAug was built for; its authors found scene replacement beat the standard baseline of random photometric distortion plus random shift.
- Colour that carries task meaning. If the task is 'pick the red block' and your hue range turns it orange, you have taught the network to ignore the feature the task depends on.
There is a fourth case that is not a lighting problem at all and constantly gets diagnosed as one: the policy was never good. One that only worked by memorising a fixed approach looks lighting-sensitive because nothing makes it robust. Move the object 5 cm under the same lighting you trained in. If it fails there too, read loss falls but the policy does nothing and fix that before touching augmentation.
What only more data fixes
| Study | Diversity in training | What it bought |
|---|---|---|
| Lin et al., Data Scaling Laws in Imitation Learning, Oct 2024 | 32 environment-object pairs, 50 demonstrations each | About 90% success in unseen environments and objects. The authors state that diversity of environments and objects beats more demonstrations per environment. |
| Physical Intelligence, Pi0.5, Apr 2025 | About 400 hours of mobile-manipulator data across about 100 homes | Ablated at 3, 12, 22, 53, 82 and 104 locations. Performance rises with more locations; near 100 it matches a model trained in the test environment itself. |
| DROID, 2024 | 76k trajectories, 350 hours, 564 scenes, 86 tasks, 50 collectors | A dataset where scene identity is not a confound at all. |
| Xie et al., Jul 2023 (simulation) | 5 to 100 training environments | The generalisation gap closes from about 0.4 to under 0.1. |
The Pi0.5 ablation should reset expectations: roughly 100 distinct environments was the point where a model generalised to a held-out home about as well as one trained inside it. You will not record 100 rooms with a single teleoperated SO-100. But that curve is steep early, and the practical reading is that going from one lighting condition to four captures most of the accessible gain.
- 1Freeze everything that is not light
Same camera position, mount, object set and task phrasing. If the tripod moves between blocks you have contaminated the experiment with the worst factor in the Xie table.
bashv4l2-ctl -d /dev/video0 --list-ctrls | grep -E 'exposure|white_balance' # write these down, reuse identical values in every block - 2Record blocks, not a shuffle
Four conditions: daylight only, daylight plus overhead, overhead only, one desk lamp at an angle. Fifteen episodes each rather than 60 in one. The desktop client writes LeRobot format straight from the teleop session; walkthrough at record your first dataset.
- 3Hold one condition out
Train on three, evaluate on the fourth. Without a held-out condition you cannot tell whether augmentation helped or whether you drew a better seed, and with GR00T there is no seed to set.
- 4Then add augmentation on top
Augmentation and diversity are complementary, not alternatives. Two checkpoints on the same three-condition dataset, one flag apart, both evaluated on the fourth. That is the comparison that answers the question for your setup.
bashlerobot-train --dataset.repo_id=$D --dataset.image_transforms.enable=false --seed=1000 ... lerobot-train --dataset.repo_id=$D --dataset.image_transforms.enable=true --seed=1000 ...
Everything above is open source and runs on your own machine. The cost is your time and a GPU.
- Lock the camera with
v4l2-ctl, same values at record and inference time. - Record four lighting blocks with
lerobot-record, one held out. - Preview with
lerobot-imgtransform-vizuntil the object stays recognisable in the worst sampled frame. - Train twice, same seed and steps, transforms on and off. SmolVLA and ACT fit a 24 GB card; GR00T N1.7 and Pi0.5 want an 80 GB A100 or H100.
- Evaluate on the held-out condition, 20 rollouts per checkpoint minimum, or you cannot separate a real effect from the variance NVIDIA documents.
lerobot-record \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM0 \
--robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \
--teleop.type=so101_leader \
--teleop.port=/dev/ttyACM1 \
--dataset.repo_id=$HF_USER/lighting-blocks \
--dataset.num_episodes=15 \
--dataset.single_task="Grab the black cube"An afternoon of recording, an evening of setup, and a GPU. The risk is not the training, it is bookkeeping: keeping four blocks, two checkpoints and one held-out set straight.
The platform removes the GPU and the plumbing, not the physics. Four lighting conditions is still four lighting conditions.
- The desktop client records LeRobot-format datasets (episodes, camera streams, joint states) straight from a teleop session, so four blocks land as one dataset with no conversion step.
- The training form picks model, dataset and hyperparameters; the backend rents a GPU on a spot market by required VRAM and writes checkpoints to object storage.
- The A100 or H100 tier (GR00T N1.7, GR00T N1.5, Pi0.5) takes 3 to 6 hours at 1.20 to 2.00 USD per hour, about 4 to 12 USD. The 24 GB tier (SmolVLA, ACT) takes 2 to 5 hours at 0.30 to 0.60 USD per hour, about 1 to 3 USD. See pricing.
- At that price the two-checkpoint comparison above is a real option. Two SmolVLA runs is a few dollars.
- The dataset directory lists public datasets, and a dataset can also come from a Hugging Face repo id: the fastest way to see whether someone already recorded your task under varied light.
The training form exposes model, dataset, batch size, learning rate, max steps, gradient accumulation and a few per-model extras (saveSteps for the GR00T models; seed and logFreq for Pi0.5 and SmolVLA; chunkSize, nActionSteps, seed and logFreq for ACT). It does not expose an image-augmentation configuration, so here your lever is the dataset, not the transform config. Separately, cloud inference latency is a problem lighting work will not solve: the loop is 20 ms per action step for ACT and up to 485 ms for Pi0.5, and public-internet round trips on top turn a working policy into a hesitant one.

Deciding which failure you actually have
Before spending money, spend ten minutes. Each row is a test you can run in the room.
| Test | If it fails | If it passes |
|---|---|---|
| Move the object 5 cm under training lighting | Not a lighting problem. The policy memorised a trajectory. | It generalises spatially. Continue. |
| Compare a live night frame against a training frame | Different exposure or colour cast: the driver is at fault. Lock the controls. | The sensor is behaving. The change is optical or statistical. |
| Turn on every light you own to match the day scene | Still fails, so light was never the cause. Check arm and servos. | Lighting confirmed. Retraining is worth it. |
| Cover the wrist camera and re-run | Identical behaviour: the policy never used that camera. | The wrist view is load-bearing, so its lighting matters most. |
A fixed overhead camera sees roughly the same illumination geometry all day. A wrist camera moves through the light field, so its exposure, shadows and highlights all vary with joint angle, in a pose-dependent way no global transform reproduces. If you can only diversify lighting for one stream, diversify it for the one on the gripper.
Something is broken and you do not know what
The failure-mode pages walk one symptom at a time: policy only works in one setup, gripper does not close, policy freezes mid-motion, arm twitches then sags. Each one has the test that separates the causes.
Open the fix indexThe short version
Lock the camera first: free, and often the whole answer. Turn on photometric augmentation second: one flag, off by default. Record a second and third lighting condition third, because no transform substitutes for it. Only then reach for a different model. Numbers for the five trainable policies are on the policies page, benchmark data in the arena. Starting from nothing, read train your first policy, the GR00T N1.7 on SO-100 guide and the data collection guide before recording anything you intend to keep.
Can photometric augmentation make the policy worse?▾
Yes. The LeRobot documentation says plainly that strong augmentations can hurt performance and that too many at once destabilise training. The failure to watch for is a hue range wide enough to change the identity of a colour-coded object. Inspect the worst sampled frame with lerobot-imgtransform-viz first, and start near the shipped defaults.
How many lighting conditions do I need in the dataset?▾
There is no published number for a single SO-100 on a desk. The evidence is indirect: Pi0.5 needed roughly 100 distinct homes before a held-out home performed like a trained-on one, and Lin et al. found 32 environment-object pairs at 50 demonstrations each enough for about 90 percent success on unseen environments. Both curves are steepest early, so one condition to three or four is where the gain sits.
Should I turn auto-exposure off on the camera?▾
For recording and inference, yes, with the same fixed values for both. Auto-exposure reacts to the arm entering the frame, making the observation distribution non-stationary in a way represented nowhere in your training data. The exception is if you cannot control ambient light at deployment, in which case leaving it on and recording under varied light beats locking to an exposure that will be wrong half the time.
Does GR00T already augment images if I do not pass --color-jitter-params?▾
Yes. In gr00t/configs/finetune_config.py the field defaults to None, and the docstring says that when it is None the default colour jitter from the pretrained model is applied, so omitting the flag is not the same as disabling augmentation. It also explains the README note that runs vary by 5 to 6 percent, with no seed field to pin them.
Can I fix a night failure without retraining anything?▾
Sometimes. If the difference is exposure or white balance, matching the camera settings to the ones used during recording can be enough. Adding light so the scene physically resembles the training scene is the other zero-cost fix. If neither works, the difference is optical or statistical and the fix is more data or a retrained checkpoint.
Sources
- Decomposing the Generalization Gap in Imitation Learning for Visual Robotic Manipulation (Xie, Lee, Xiao, Finn, 2023)
- Factor World: MetaWorld environments with 11 controllable factors of variation
- RT-1: Robotics Transformer for Real-World Control at Scale
- BC-Z: Zero-Shot Task Generalization with Robotic Imitation Learning
- Data Scaling Laws in Imitation Learning for Robotic Manipulation (Lin et al., 2024)
- Pi0.5: a Vision-Language-Action Model with Open-World Generalization
- Green Screen Augmentation Enables Scene Generalisation in Robotic Manipulation
- Planckian Jitter: countering the color-crippling effects of color jitter on self-supervised training
- LeRobot image transform source: ImageTransformsConfig defaults and the custom transform registry
- LeRobotDataset v3.0 documentation, including the image transforms section
- LeRobot GR00T documentation with the image_transforms training flags
- Isaac-GR00T FinetuneConfig: color_jitter_params, tune_visual, extra_augmentation_config
- Isaac-GR00T: fine-tuning a new embodiment, with the SO-100 example
- Linux V4L2 user controls: power line frequency, white balance temperature, gain
- Linux V4L2 camera control reference: exposure auto and exposure absolute
Ready for high-quality robotics data?
AY-Robots connects your robots to skilled operators worldwide.
Get Started