Camera timing · metadata and a moving target
Camera Module 3: A Frame Timestamp Is Not One Exposure Instant
Photograph a slowly moving striped card, then inspect the image and timing metadata from the same camera request. The useful result is a better question: which part of the image does a timestamp describe? This experiment records what the software actually returns while leaving the sensor's unknown row timing unknown.
1. One image can contain several times
A rolling shutter exposes different rows of an image at different times. It can bend a moving straight edge even when the camera is stationary. That is different from motion blur: blur spreads an object's image during one exposure interval, while rolling readout also changes the interval between rows. Both effects can appear together. A sharp-looking image is not proof that every row observed the same event.
The intended hardware is a Raspberry Pi 4 Model B and the standard visible-light Camera Module 3 containing Sony's IMX708 sensor. This is not the wide, NoIR or Global Shutter Camera. NoIR means the infrared-removal filter is absent; that variant is outside this fixture. The program uses the application programming interface (API) of Picamera2 version 0.3.38. It targets Raspberry Pi OS on the Pi; host tests use substitute camera requests and do not run a camera, libcamera image pipeline or sensor.
CSI means Camera Serial Interface, the dedicated connection carrying camera signals through the ribbon assembly. GPIO means general-purpose input/output; none of the Pi's GPIO header pins are wired here. USB means Universal Serial Bus; the Pi receives power through its USB-C connector. Units are volts (V), amperes (A), milliseconds (ms), microseconds (µs) and nanoseconds (ns). One millisecond equals one million nanoseconds.
Metadata is information attached to an image, such as reported exposure duration and a sensor timestamp. It describes the camera pipeline's observations and configuration, not a calibrated stopwatch attached to every pixel. Keep the original metadata, a note about the target and the exact software configuration together. A file modification time is yet another clock and is not substituted for an exposure time in this lesson.
2. Connect an actual module assembly

Use the standard 15-contact camera cable supplied for this camera and a Pi 4. The Pi 5 and Zero families use a different board-side connector; do not force their cable into this setup. Shut down, remove power and open both connector latches before inserting the ribbon. Follow Raspberry Pi's illustrated orientation guide for the exact board and camera faces. Seat the cable straight and close the latches evenly. The schematic does not replace that mechanical instruction.
Power the Pi through an appropriate official 5.1 V, 3 A USB-C supply. Do not add a second supply to the camera connector or solder to the bare IMX708. The camera module receives its intended power and control signals through the manufacturer's assembly. Check the physical camera identity yourself: the software's imx708 model name cannot distinguish every lens and filter variant. The passive adapter and bare-sensor pinout questions that matter in other lessons are intentionally absent here.
Mount the Pi and camera firmly on a dry tabletop. Put a vertical black-and-white striped card roughly one metre away, with a stationary reference edge beside it. Move the card slowly sideways by hand. Use ordinary steady room illumination, without a rapidly flashing lamp or a rotating propeller. This avoids adding an unqualified light-driver circuit or dangerous moving mechanism. The worksheet records illumination, distance, movement direction and which side of the card was nearest the lens.
3. Record the software that really runs
Use a matching Raspberry Pi OS camera stack, including libcamera and its Python bindings. A binding connects Python calls to the underlying camera library. The program refuses a Picamera2 package version other than 0.3.38 and a non-Linux main run. Its record includes the actual Python/platform identity, selected operating-system release fields and installed libcamera package versions queried with dpkg-query. It does not invent the version of an unavailable Pi.
Before connecting the camera, update and inspect the distribution packages using the official camera installation instructions. Do not install one isolated Python package over incompatible system bindings merely to satisfy the version string. If your package repository provides another release, preserve that fact and qualify its actual API separately before changing the experiment. The pinned upstream source was reviewed here; successful host tests do not establish that a particular Secure Digital (SD) card image contains the same binaries.
python3 -c "import importlib.metadata; print(importlib.metadata.version('picamera2'))"
dpkg-query -W 'libcamera*' python3-libcamera
python3 camera_rolling_time.py NEW_EXPERIMENT_DIRECTORYThe output directory must not exist. This prevents silently mixing a new run with earlier photographs. A successful run contains eight named JPEG images and report.json. JPEG, named for the Joint Photographic Experts Group, is a compressed image format; compression can alter edge appearance, so this lesson does not infer exact pixel displacements from its saved images. On failure, partial images may remain, but there is no successful report. Keep them as failed-run evidence rather than relabelling them as an accepted sequence.
4. Read the timestamp definition before subtracting
The current Picamera2 manual describes SensorTimestamp as nanoseconds since system boot, sampled around the start-of-frame interrupt as the first pixel is written out by the sensor. It is not described as the midpoint of every row's exposure. The manual's documented first-pixel start estimate subtracts ExposureTime, expressed in microseconds, after converting it to nanoseconds. This relation is useful within the documented Raspberry Pi camera stack; it is not a measurement of the line period.
documented first-pixel exposure start = SensorTimestamp - 1000 × ExposureTime
example metadata only: 2,000,000,000 ns - 1000 × 10,000 µs
= 1,990,000,000 ns
Unknown in this experiment: each real row's offset and every pixel's event time.The code asks capture_request for flush=True. In the pinned implementation this compares the timestamp minus exposure duration against the call's monotonic time and discards older completed requests. Monotonic means a clock intended to advance without wall-clock corrections. Our outer call bracket is recorded as well. A returned frame whose documented first-pixel start precedes that bracket is rejected, rather than treated as a new observation simply because a Python function returned.
The checks also reject missing fields, backwards or duplicate sensor timestamps, a reversed host bracket, a returned capture span over 2.5 seconds and timing outside this experiment's declared domain. These are application acceptance rules, not published sensor limits. A clock-domain change in another backend can therefore reject a usable image; it does not justify weakening the checks without reviewing that backend's timestamp contract.
Do not divide FrameDuration by 720 and announce a line time. The output height is a processed stream size, not necessarily the number of sensor rows read in the selected mode. Blanking, cropping, binning, scaling and mode-dependent timing matter. The report records ScalerCrop and the actual configured camera description; neither field alone supplies all those timing parameters. No exact IMX708 row-time constant is embedded in the program.
5. Use a constructed diagram without converting it into data

In this constructed example the first row integrates from 0 to 10 ms, while the eighth integrates from 14 to 24 ms. The first-row readout reference at 10 ms falls inside some later exposure windows. There is no single instant that describes all eight rectangles. The complete program supplies the small illustrative_windows function so its assumed geometry can be inspected and tested independently from the camera adapter.
The diagram does not identify the scan direction of a rotated or flipped final image. The actual stream may be transformed, cropped or scaled. Nor does it predict how much a hand-moved card will bend: hand speed is not constant, the lens projects perspective, and the target may rotate. Use the observed image as a qualitative comparison and record the limitations before attempting any separate calibrated optical timing experiment.
6. Complete finite capture program
The adapter requests a 1280 × 720 RGB stream, eight captures, a nominal 10,000 µs exposure and 33,333 µs frame duration. RGB means red, green and blue colour channels. Automatic exposure is disabled, manual focus is requested at lens position 1.0 and gain is requested as 1.0. LensPosition uses dioptres, reciprocal metres: 1.0 is a nominal one-metre focus request, not a guaranteed object distance. Requested controls are not proof of their exact physical effect; use the reported ExposureTime and actual image quality when comparing captures. The worksheet retains the requested settings separately.
"""Camera Module 3 metadata experiment, pinned Picamera2 0.3.38 adapter.
No camera execution or physical row-time estimate follows from host tests.
"""
import importlib.metadata
import json
from pathlib import Path
import platform
import time
def integer(value, name, low=0):
if type(value) is not int or value < low:
raise ValueError(name)
return value
def validate_metadata(md, before_ns, after_ns, previous=None):
before_ns=integer(before_ns,'HOST_CLOCK'); after_ns=integer(after_ns,'HOST_CLOCK')
if after_ns < before_ns or after_ns-before_ns > 2_500_000_000:
raise ValueError('RETURNED_CAPTURE_SPAN')
stamp=integer(md.get('SensorTimestamp'),'SENSOR_TIMESTAMP',1)
exposure=integer(md.get('ExposureTime'),'EXPOSURE_US',1)
duration=integer(md.get('FrameDuration'),'FRAME_DURATION_US',1)
if not 100 <= exposure <= 20000 or not exposure <= duration <= 1000000:
raise ValueError('EXPERIMENT_TIMING_DOMAIN')
start=stamp-1000*exposure
if start < before_ns or stamp > after_ns:
raise ValueError('FLUSH_OR_CLOCK_ASSUMPTION')
if previous is not None and stamp <= previous:
raise ValueError('NONADVANCING_FRAME')
crop=md.get('ScalerCrop')
if not isinstance(crop,(tuple,list)) or len(crop)!=4 or any(type(x)is not int for x in crop) or min(crop[2:])<=0:
raise ValueError('SCALER_CROP')
return {'SensorTimestamp_ns':stamp,'ExposureTime_us':exposure,
'FrameDuration_us':duration,'ScalerCrop':list(crop),
'host_call_before_ns':before_ns,'host_return_after_ns':after_ns,
'documented_first_pixel_exposure_start_ns':start,
'row_time_ns':None,'all_pixel_event_time_ns':None,
'scope':'Historical metadata; no measured row timing or optical accuracy'}
def collect(camera, directory, clock=time.monotonic_ns, count=8):
"""Directory must be a NEW caller-owned experiment directory."""
directory=Path(directory); rows=[]; primary=None; errors=[]
try:
if type(count)is not int or not 1 <= count <= 8:raise ValueError('COUNT_1_TO_8')
directory.mkdir(exist_ok=False)
if str(camera.camera_properties.get('Model','')).lower()!='imx708':raise ValueError('IMX708_REQUIRED')
config=camera.create_video_configuration(main={'size':(1280,720),'format':'RGB888'},raw=None,queue=False,
controls={'AeEnable':False,'ExposureTime':10000,'AnalogueGain':1.0,
'AfMode':0,'LensPosition':1.0,'FrameDurationLimits':(33333,33333)})
camera.configure(config)
configured=camera.camera_configuration()
camera.start(show_preview=False)
previous=None
for index in range(count):
before=clock(); request=None
try:
request=camera.capture_request(wait=2.0,flush=True)
md=request.get_metadata(); after=clock()
row=validate_metadata(md,before,after,previous)
name='frame-%02d.jpg'%index
request.save('main',str(directory/name))
row['image']=name; rows.append(row); previous=row['SensorTimestamp_ns']
finally:
if request is not None:request.release()
except BaseException as exc:
primary=exc
finally:
# Timeout does not cancel a Picamera2 job. Flush before stopping/closing.
for name in ('cancel_all_and_flush','stop','close'):
try:getattr(camera,name)()
except BaseException as exc:errors.append((name,type(exc).__name__))
if primary is not None:raise primary
if errors:raise RuntimeError('CLEANUP_UNCONFIRMED '+repr(errors))
return {'state':'CLOSED_HISTORICAL_METADATA','rows':rows,
'configured':repr(configured),'camera_execution_by_host_tests':False,
'physical_row_time_measured':False}
def illustrative_windows(rows=8, exposure_ms=10, assumed_row_step_ms=2):
if type(rows)is not int or not 2<=rows<=32:raise ValueError('MODEL_ROWS')
if any(type(x) not in (int,float) or not __import__('math').isfinite(x) for x in (exposure_ms,assumed_row_step_ms)) or not 0<exposure_ms<=100 or not 0<assumed_row_step_ms<=100:raise ValueError('MODEL_DOMAIN')
return [{'row':i,'start_ms':i*assumed_row_step_ms,
'end_ms':i*assumed_row_step_ms+exposure_ms} for i in range(rows)]
def main():
import sys
if len(sys.argv)!=2:raise SystemExit('python camera_rolling_time.py NEW_DIRECTORY')
if importlib.metadata.version('picamera2')!='0.3.38':raise RuntimeError('PIN_PICAMERA2_0_3_38')
if platform.system()!='Linux':raise RuntimeError('RASPBERRY_PI_LINUX_TARGET_REQUIRED')
import subprocess
packages=subprocess.run(['dpkg-query','-W','-f=${binary:Package}=${Version}\n','libcamera*','python3-libcamera'],capture_output=True,text=True,timeout=5,check=True).stdout
os_release={line.split('=',1)[0]:line.split('=',1)[1].strip().strip(chr(34)) for line in Path('/etc/os-release').read_text().splitlines() if '=' in line and line.split('=',1)[0] in ('ID','PRETTY_NAME','VERSION_ID')}
from picamera2 import Picamera2
report=collect(Picamera2(),sys.argv[1])
report['runtime']={'python':platform.python_version(),'platform':platform.platform(),
'picamera2':importlib.metadata.version('picamera2'),
'libcamera_packages':packages,'os_release':os_release}
path=Path(sys.argv[1])/'report.json'
path.write_text(json.dumps(report,indent=2)+'\n')
print(path)
if __name__=='__main__':main()
Each completed request provides both its metadata and saved image, then is released in a finally block. Releasing returns a buffer reference to the camera system; retaining it indefinitely can starve later captures. A timeout is not a cancellation in the pinned Job implementation. Cleanup therefore attempts cancel_all_and_flush, stop and close independently, including configuration and capture failures. A reported cleanup failure prevents a successful result. This is best-effort software cleanup, not a promise that a hung kernel or disconnected cable can be interrupted by Python.
The capture wait has a software timeout, but image encoding, filesystem writes and teardown do not acquire a physical hard deadline through that setting. No image is described as the current scene after the program stops. The final report is historical. Keep a failed directory separate from a successful one; do not rescue an old good image to hide a later error.
7. Make comparisons that answer the question
- With power removed, identify the exact module, cable and Pi connector. Record them in the blank worksheet, then connect and power the assembly.
- Record the package and operating-system versions. Run the program first with the card stationary. Inspect the eight images and their metadata; refuse missing or rejected records.
- Use a second new directory with slow sideways card motion. Keep the stationary reference in frame and avoid moving the camera. Record which direction and approximate hand movement were used, without treating hand motion as a calibrated speed.
- Compare apparent edge shape with the stationary run. Note blur, focus changes, lighting bands and perspective separately. A bent edge is evidence to investigate, not a row-time calibration.
- Read the exact exposure and frame duration returned for each saved image. Compare advancing sensor timestamps with the host capture brackets. Preserve gaps rather than assuming eight captures are eight consecutive sensor frames.
- Test a software rejection safely on the host with the supplied harness. Do not unplug the live ribbon as a fault experiment. Shut down and remove power before any cable investigation.
The host harness runs the actual supplied validator and capture loop with fake requests, including timeout, malformed metadata, duplicate frames, image-save errors, interrupts and independent cleanup failures. Its tiny fake image files are labelled host test data and are not camera photographs. No Raspberry Pi, optical exposure, autofocus or target execution was performed for this preparation.
8. What the result cannot establish
A monotonic timestamp has no universal conversion to civil time without a separate clock relationship. A first-pixel reference does not establish another row's start, a moving object's crossing instant or synchronization with a different machine. Python return time includes queueing and processing, and filesystem modification time includes storage behavior. Keep all three meanings distinct.
Changing exposure can change blur without removing rolling-shutter skew. Changing output resolution can select a different sensor mode and alter readout behavior. A successful eight-frame run does not prove lossless acquisition, absence of dropped frames, low-light performance or a hardware safety limit. These questions need additional primary timing information and physical measurement. This lesson deliberately leaves the corresponding row and event-time fields null.
9. Challenge: will a shorter exposure make every row simultaneous?
A moving striped card is blurred and tilted. Would reducing exposure duration guarantee an untilted image?
Show the answer
No. Shorter integration can reduce blur within each row while the row start offsets remain. Their intervals may still observe different object positions. The sensor mode and actual row timing must be qualified separately; a requested exposure or frame duration is not enough.
10. Sources and complete experiment files
Read the official camera connection and rolling-shutter guide, the current Picamera2 manual and Picamera2 0.3.38. The pinned capture, request-release, timeout and flush paths are in the official source. The manual was read through the web tool; a separate direct PDF fetch returned 403 and was not retried. That access limit does not become an invented archived PDF.
Complete program · Blank observation sheet · Compressed ZIP archive with tests and originals. Continue with the electronics course. All experimental result fields remain blank until a real run is performed.