Skip to content

Loading Captures#

cg.load(path, Schema) materializes a captured target as typed Python values. The schema is the one you authored the procedure with — it drives the walk, so there is no manifest to read.

cg.load#

import capturegraph as cg


class Session(cg.Struct):
    photo: cg.Image
    notes: cg.String
    rating: cg.Number
    location: cg.Location


class Survey(cg.Struct):
    reference: cg.Image
    sessions: cg.Map[cg.Date, Session]


survey = cg.load("/path/to/MySurvey", Survey)

cg.load returns the loaded Survey. Each position is decoded by its type: JSON scalars become typed values, file scalars hold a path plus their helpers, and composites become broadcasting containers.

You can also pass a schema dict (the schema block from a serialized procedure) instead of the class:

survey = cg.load("/path/to/MySurvey", cg.schema(Survey))

On disk#

The schema is the layout, so a target you copy off a phone mirrors it position for position. The Survey above lays out like this:

MySurvey/                       # the target — the Survey struct
├── reference.heic              # reference: cg.Image  (a file scalar leaf)
└── sessions/                   # sessions: cg.Map[cg.Date, Session]
    ├── 0006380070917400/       # one entry — a 16-hex-microsecond session id
    │   ├── photo.heic          # photo: cg.Image
    │   ├── notes.json          # notes: cg.String   (JSON scalars are .json)
    │   ├── rating.json         # rating: cg.Number
    │   └── location.json       # location: cg.Location
    └── 0006382D486C3A00/       # the next entry, sorting after it
        ├── photo.heic
        ├── notes.json
        ├── rating.json
        └── location.json

The rules that generate this are the same on every platform:

  • A cg.Struct is a directory; each field is a child named for the field.
  • A field whose name starts with _ is hidden — it stores under a leading . (a _metadata field becomes .metadata), so a target's bookkeeping stays out of the way.
  • A cg.Map[cg.Time, V] (and its cg.Date alias) stores each entry under a 16-hex-microsecond folder — the session id, which sorts chronologically. A cg.Map[cg.String, V] or cg.Map[cg.UserID, V] uses the key text itself.
  • A cg.Array[T] stores each element under a zero-padded index (00000, 00001, …), so position order is name order.
  • A file scalar is the leaf file, keeping its own extension (.heic, .dng, .jpeg for an image); a JSON scalar is a .json leaf.

You never write this tree by hand — the iOS client and server lay it down from the embedded schema — but seeing it is the fastest way to recognize what cg.load is walking.

Accessing data#

Struct fields#

Fields are attributes:

reference = survey.reference        # an Image (path + helpers)

Maps and Arrays#

A cg.Map keeps its keys and broadcasts attribute access over its values:

sessions = survey.sessions

for when, session in sessions.items():    # keys are datetimes
    print(when, session.notes.value)

sessions.keys()                           # every capture time
sessions.rating                           # broadcast: an Array of Number
sessions.rating.value                     # chain through: [5, 3, 4, ...]
sessions.location.latitude                # nested broadcast: [42.0, ...]

A cg.Array works the same way, indexed by position. A file-sequence — a burst of images captured as cg.Array[cg.Image] — loads from its zero-padded index folders (burst/00000.heic, burst/00001.heic, …) as an Array in order:

class Session(cg.Struct):
    burst: cg.Array[cg.Image]               # a file sequence: 00000.heic, ...


frames = session.burst                      # an Array of Image, in order
frames[0]                                   # the first frame
frames.exif()                               # broadcast a helper over all frames

A nested struct nests the same way — an Array of sub-structs broadcasts a field across every element, so projection descends through the directories the schema laid out:

class Reading(cg.Struct):
    value: cg.Number
    note: cg.String


class Session(cg.Struct):
    readings: cg.Array[Reading]             # readings/00000/value.json, ...


session.readings[0].value.value             # one reading
session.readings.value.value                # every reading's value: [1, 2, ...]

File scalars#

A file scalar loads as its path value — a cg.Image that is a Path and carries its decode helpers as methods, so no pixels are read until you ask:

photo = session.photo               # a cg.Image
photo.exif().datetime_original      # typed EXIF — a cg.Exif
photo.pil()                         # a PIL image
photo.tooltip()                     # a small preview

A loaded cg.Video carries metadata(), frame_at(t), and thumbnail() likewise. See Types and Nodes.

Missing data#

Field captures are messy: sessions get interrupted, optional steps get skipped. A failed lookup returns cg.Missing — a null object that absorbs further access, so a chain like session.weather.condition is always safe. Test for it with cg.is_missing:

for session in survey.sessions.values():
    if cg.is_missing(session.notes):
        print("no notes")
    else:
        print(session.notes.value)

cg.is_missing(None) is also TrueNone counts as missing.

Writing back#

To persist a value to a target, cg.commit stores it through the schema:

loc = cg.Location(latitude=42.0, longitude=-76.0)
cg.commit("/path/to/MySurvey/reference_location", loc, cg.Location)

Into pandas#

Broadcast columns drop straight into a DataFrame:

import pandas as pd

sessions = survey.sessions
df = pd.DataFrame({
    "date": list(sessions.keys()),
    "rating": sessions.rating.value.to_numpy(),
    "notes": list(sessions.notes.value),
})

Because the schema was fixed before capture, this script keeps working as new sessions arrive — write it on day one and rerun it for the life of the study.

See Also#