Repository: voxel51/fiftyone
Pull request: Keypoint updates
Resolves #1581

Requires https://github.com/voxel51/eta/pull/556

## Change log

- [x] Implement keypoint skeletons as per #1563
- [x] Implement skeleton rendering in `draw_labels()`
- [x] Add a `filter_keypoints()` view stage that applies per-`point` filters to keypoint objects
- [x] Remove `visible` usage in favor of `NaN`-based filtering
- [x] Update `draw_labels()` code to gracefully handle NaN-valued keypoints
- [x] Documentation
- [x] Add keypoint skeleton (`label` and `edges`) rendering in the App
    - [x] Add an option to the App's settings menu to toggle whether skeletons are rendered
    - [x] Add skeleton labels and any `ListField` field values (whose length is `len(points)`) to the tooltip of individual points
- [x] Add support for custom per-point attributes on keypoints (occluded, etc)
- [x] Update App keypoint filtering logic:
    - [x] Add a `skeleton.label` filter that allows for filtering keypoints by their skeleton label
    - [x] Add a `confidence` filter that allows for filtering keypoints by `Keypoint.confidence`
- [x] ~~Convert `KeypointSkeleton` to just a dict?~~
- [x] ~~Add support for per-`label` skeletons in a field?~~

App implementation details
- The `Keypoint.points` filter logic should insert `NaN` values rather than removing the elements from the list (since the skeleton is defined based on list index position)
- Any `Keypoint.points` entry that contains a NaN coordinates should not be rendered in the App
- Any skeleton edges between one or more points with NaN coordinates should not be rendered
- The new `filter_keypoints()` view stage supports filtering by per-point confidences and per-point skeleton labels

## Example usage

```py
import numpy as np

import fiftyone as fo
import fiftyone.zoo as foz
from fiftyone import ViewField as F

dataset = foz.load_zoo_dataset("coco-2017", split="validation").clone()

model = foz.load_zoo_model("keypoint-rcnn-resnet50-fpn-coco-torch")
dataset.default_skeleton = model.skeleton

view = (
    dataset
    .match_labels(fields="ground_truth", filter=F("label") == "person")
    .limit(2)
)

view.apply_model(model, label_field="predictions")

# Add some custom attributes
for sample in view:
    for keypoint in sample["predictions_keypoints"].keypoints:
        keypoint["occluded"] = [np.random.randn() > 0 for _ in range(len(keypoint.points))]
        keypoint["age"] = np.random.randint(51)

    sample.save()

print(view.bounds("predictions_keypoints.keypoints.confidence"))
print(dataset.default_skeleton)

session = fo.launch_app(view)

session.view = view.filter_keypoints(
    "predictions_keypoints",
    filter=F("confidence") > 0.99999,
)

session.view = view.filter_keypoints(
    "predictions_keypoints",
    labels=["left eye", "right eye"],
)

view.draw_labels(
    "/tmp/keypoints",
    label_fields="predictions_keypoints",
    overwrite=True,
)
```

### Keypoint skeleton construction

```py
skeleton = fo.KeypointSkeleton(
    labels=[
        'nose',
        'left eye',
        'right eye',
        'left ear',
        'right ear',
        'left shoulder',
        'right shoulder',
        'left elbow',
        'right elbow',
        'left wrist',
        'right wrist',
        'left hip',
        'right hip',
        'left knee',
        'right knee',
        'left ankle',
        'right ankle',
    ],
    edges=[
        [11, 5, 3, 1, 0, 2, 4, 6, 12],
        [9, 7, 5, 6, 8, 10],
        [15, 13, 11, 12, 14, 16],
    ]
)
```


Problem statement:
[FR] Add support for per-point confidence/visibility 
### Proposal Summary

Add support for per-point confidence/visibility - currently Keypoint label class (which represents a set of points associated with an instance) is a flat list of (x, y) which doesn't provide additional per-point information. In addition, Coco import skips over the point which visibility = 0 (not visible/annotated) which can break the implicit mapping between joint -> point since num_joints != num_points and there's no information on which joint was skipped.

### Motivation

-   What is the use case for this feature?
This is needed to correctly import COCO keypoint dataset as well as filter sample based on per-point confidence...etc.

-   Why is this use case valuable to support for FiftyOne users in general?
Anyone using keypoint detection would probably need this since most of models outputs per-point confidence and there needs to be a way to distinguish in ground truth whether the point is visible or not + mapping between point to joint.

-   Why is this use case valuable to support for your project(s) or
    organization?
My project needs keypoint dataset - was hoping to leverage existing COCO dataset however the import breaks the mapping so any partially annotated keypoints (e.g. missing things) are useless.

-   Why is it currently difficult to achieve this use case? (please be as
    specific as possible about why related FiftyOne features and components are
    insufficient)
One way to workaround would be to only use fully annotated samples (no v=0), which would probably throw out majority of keypoint annotations.


### What areas of FiftyOne does this feature affect?

-   [x] App: FiftyOne application
-   [x] Core: Core `fiftyone` Python library
-   [ ] Server: FiftyOne server

### Details

(Use this section to include any additional information about the feature. If
you have a proposal for how to implement this feature, please include it here.)

We probably need to fix COCOObject::to_keypoints to not skip over v == 0 case and encode this visibility information to Keypoint by

Option 1. Expand KeypointsField to include confidence/visibility (not sure how it would support "optional-ness")
Option 2. Add a separate field "confidences"/"visibilities" in Keypoint class which maps to "points"

### Willingness to contribute

The FiftyOne Community encourages new feature contributions. Would you or
another member of your organization be willing to contribute an implementation
of this feature?

-   [ ] Yes. I can contribute this feature independently.
-   [x] Yes. I would be willing to contribute this feature with guidance from
        the FiftyOne community.
-   [ ] No. I cannot contribute this feature at this time.

I would love to contribute, however I'm not a Python expert + new to FiftyOne so would probably need some guidance.

Hints from issue discussion:
Thanks for the FR!

One option we have to represent not visible is to insert `(NaN, NaN)` for those points. The app allows this, and won't render the points. In this case, we would not need a separate way to store visibility flags.

However, `visible=True/False` is a more generic concept that we could introduce for other label types like `Detections`. So, I think I'm inclined to adopt your idea of optional parallel lists of data. Here's what I'm thinking for naming:

- `points`: a list of `(x, y)` keypoints in `[0, 1] x [0, 1]`. If `visible[i] == False` then `points[i]` can be arbitrary
- `confidences`: an optional list of confidences corresponding to `points`. Defaults to `None`
- `visible`: an optional list of `True/False` indicating whether each point is visible. If not provided, all points are assumed to be visible

Cool, I looked into confidence->confidences and it seems "keypoint-rcnn-resnet50-fpn-coco-torch" does return both "scores" (which I think is mapped to detection instances) and "keypoint_scores" (undocumented but shown in the output) so we can probably replace confidence->confidences and populate per-point confidence from "keypoint_scores" here.
Cool. so here's roughly what needs to be done to make this happen on the backend:

1. Update the definition of the [Keypoint class](https://github.com/voxel51/fiftyone/blob/cd13869ce589644ddfc19693905f6783b3202f4d/fiftyone/core/labels.py#L848) to replace the existing `confidence` field with a `confidences` field that is a nullable float-list. You can define that like so:

```
confidences = fof.ListField(field=fof.FloatField(), null=True)
```

That will give us the behavior we want: all `Keypoint` instances will now have a `confidences` field that is `None` by default but is also allowed to be a list of floats.

2. Update any relevant `Keypoint` logic in the codebase to use confidences where applicable. I think you're right that the only real builtin place that uses keypiont confidences are zoo models like `keypoint-rcnn-resnet50-fpn-coco-torch`, and the relevant code to change there is the [KeypointDetectorOutputProcessor](https://github.com/voxel51/fiftyone/blob/cd13869ce589644ddfc19693905f6783b3202f4d/fiftyone/utils/torch.py#L802) class 

3. Update the App to render per-point confidences in the tooltip, if available. We'll need @benjaminpkane's help on that one
My suggestion would be that we save the `visible` field for separate work, since that's a feature that can apply to all label types.

In the meantime, all `Label` types can already have arbitrary fields added to them:

```py
import fiftyone as fo

kp1 = fo.Keypoint(
    label="cat",
    points=[[0, 0], [1, 1]],
    visible=[True, False],
)
```

So I'm basically just saying let's wait on declaring `visible` as a default field and expecting the App to recognize and support it

Commit message: skeleton work

Patch to review:
diff --git a/app/packages/app/src/components/Actions/Options.tsx b/app/packages/app/src/components/Actions/Options.tsx
index 6b2d6d8e3cd..4c8ae926eba 100644
--- a/app/packages/app/src/components/Actions/Options.tsx
+++ b/app/packages/app/src/components/Actions/Options.tsx
@@ -45,25 +45,25 @@ export const RefreshButton = ({ modal }) => {
 };
 
 const ColorBy = ({ modal }) => {
-  const [colorByLabel, setColorByLabel] = useRecoilState(
-    atoms.colorByLabel(modal)
+  const [colorByValue, setColorByValue] = useRecoilState(
+    selectors.appConfigOption({ modal, key: "color_by_value" })
   );
 
   return (
     <>
       <PopoutSectionTitle>Color by</PopoutSectionTitle>
       <TabOption
-        active={colorByLabel ? "value" : "field"}
+        active={colorByValue ? "value" : "field"}
         options={[
           {
             text: "field",
             title: "Color by field",
-            onClick: () => colorByLabel && setColorByLabel(false),
+            onClick: () => colorByValue && setColorByValue(false),
           },
           {
             text: "value",
             title: "Color by value",
-            onClick: () => !colorByLabel && setColorByLabel(true),
+            onClick: () => !colorByValue && setColorByValue(true),
           },
         ]}
       />
@@ -71,6 +71,22 @@ const ColorBy = ({ modal }) => {
   );
 };
 
+const Skeletons = ({ modal }) => {
+  const [shown, setShown] = useRecoilState(
+    selectors.appConfigOption({ key: "show_skeletons", modal })
+  );
+
+  return (
+    <>
+      <Checkbox
+        name={"Show keypoint skeletons"}
+        value={shown}
+        setValue={(value) => setShown(value)}
+      />
+    </>
+  );
+};
+
 const Opacity = ({ modal }) => {
   const theme = useTheme();
   const [alpha, setAlpha] = useRecoilState(atoms.alpha(modal));
@@ -168,6 +184,7 @@ const Options = ({ modal, bounds }: OptionsProps) => {
       <RefreshButton modal={modal} />
       <Opacity modal={modal} />
       <SortFilterResults modal={modal} />
+      <Skeletons modal={modal} />
       <Patches modal={modal} />
     </Popout>
   );
diff --git a/app/packages/app/src/components/FieldsSidebar.tsx b/app/packages/app/src/components/FieldsSidebar.tsx
index f34c78c180d..532ac6a30e8 100644
--- a/app/packages/app/src/components/FieldsSidebar.tsx
+++ b/app/packages/app/src/components/FieldsSidebar.tsx
@@ -448,7 +448,9 @@ const LabelsCell = ({ modal, frames }: LabelsCellProps) => {
     useRecoilValue(filterAtoms.labelCounts({ key, modal })),
   ];
 
-  const colorByLabel = useRecoilValue(atoms.colorByLabel(modal));
+  const colorByValue = useRecoilValue(
+    selectors.appConfigOption({ key: "color_by_value", modal })
+  );
   const theme = useTheme();
 
   return (
@@ -474,7 +476,7 @@ const LabelsCell = ({ modal, frames }: LabelsCellProps) => {
           hideCheckbox: false,
           hasDropdown: FILTERABLE_TYPES.includes(types[path]),
           selected: activeLabels.includes(path),
-          color: colorByLabel ? theme.brand : colorMap(path),
+          color: colorByValue ? theme.brand : colorMap(path),
           title: name,
           path: path,
           type: "labels",
@@ -554,7 +556,9 @@ const OthersCell = ({ modal }: OthersCellProps) => {
   const [activeScalars, setActiveScalars] = useRecoilState(
     fieldAtoms.activeScalars(modal)
   );
-  const colorByLabel = useRecoilValue(atoms.colorByLabel(modal));
+  const colorByValue = useRecoilValue(
+    selectors.appConfigOption({ key: "color_by_value", modal })
+  );
   const theme = useTheme();
   const dbFields = useRecoilValue(selectors.primitivesDbMap("sample"));
 
@@ -604,7 +608,8 @@ const OthersCell = ({ modal }: OthersCellProps) => {
             hideCheckbox: modal,
             hasDropdown: !modal || Array.isArray(value),
             selected: activeScalars.includes(name),
-            color: colorByLabel ? theme.brand : colorMap(name),
+            color: colorByValue ? theme.brand : colorMap(name),
+
             title:
               modal &&
               (!Array.isArray(value) || types[name] === FRAME_SUPPORT_FIELD)
diff --git a/app/packages/app/src/components/Filters/CategoricalFilter.tsx b/app/packages/app/src/components/Filters/CategoricalFilter.tsx
index 41d7fdfffda..2de5b0a41ac 100644
--- a/app/packages/app/src/components/Filters/CategoricalFilter.tsx
+++ b/app/packages/app/src/components/Filters/CategoricalFilter.tsx
@@ -367,7 +367,7 @@ const useSearch = () => {
 };
 
 interface Props {
-  countsAtom: RecoilValueReadOnly<{
+  countsAtom?: RecoilValueReadOnly<{
     count: number;
     results: [Value, number][];
   }>;
diff --git a/app/packages/app/src/components/Filters/LabelFieldFilter.tsx b/app/packages/app/src/components/Filters/LabelFieldFilter.tsx
index 310b75867f6..fd019596805 100644
--- a/app/packages/app/src/components/Filters/LabelFieldFilter.tsx
+++ b/app/packages/app/src/components/Filters/LabelFieldFilter.tsx
@@ -18,6 +18,7 @@ import * as atoms from "../../recoil/atoms";
 import * as selectors from "../../recoil/selectors";
 import * as stringField from "./StringFieldFilter.state";
 import { countsAtom } from "./atoms";
+import { KEYPOINT, KEYPOINTS } from "@fiftyone/looker/src/constants";
 
 const FilterHeader = styled.div`
   display: flex;
@@ -79,10 +80,12 @@ type Props = {
 const LabelFilter = ({ expanded, entry, modal }: Props) => {
   const [ref, props] = useExpand(expanded);
   const path = `${entry.path}${getPathExtension(entry.labelType)}`;
+  const skeleton = useRecoilValue(selectors.skeleton(entry.path));
   const cPath = `${path}.confidence`;
   const lPath = `${path}.label`;
   const sPath = `${path}.support`;
   const vPath = `${path}.value`;
+  const kPath = `${path}.points.label`;
 
   return (
     <animated.div style={{ ...props }}>
@@ -114,6 +117,22 @@ const LabelFilter = ({ expanded, entry, modal }: Props) => {
               fieldType={FLOAT_FIELD}
             />
           )}
+          {skeleton && [KEYPOINTS, KEYPOINT].includes(entry.labelType) && (
+            <CategoricalFilter
+              color={entry.color}
+              name={"Skeleton.labels"}
+              valueName={"label"}
+              selectedValuesAtom={stringField.selectedValuesAtom({
+                modal,
+                path: kPath,
+              })}
+              countsAtom={countsAtom({ modal, path: kPath, filtered: false })}
+              excludeAtom={stringField.excludeAtom({ modal, path: kPath })}
+              disableItems={true}
+              modal={modal}
+              path={kPath}
+            />
+          )}
           {entry.labelType === REGRESSION && (
             <NamedRangeSlider
               color={entry.color}
diff --git a/app/packages/app/src/components/Filters/LabelFieldFilters.state.tsx b/app/packages/app/src/components/Filters/LabelFieldFilters.state.tsx
index 854955c5ba1..57a2b955df1 100644
--- a/app/packages/app/src/components/Filters/LabelFieldFilters.state.tsx
+++ b/app/packages/app/src/components/Filters/LabelFieldFilters.state.tsx
@@ -262,6 +262,7 @@ export const labelFilters = selectorFamily<LabelFilters, boolean>({
         return (
           (inRange ||
             noConfidence ||
+            Array.isArray(s.confidence) ||
             (cNan && s.confidence === "nan") ||
             (cInf && s.confidence === "inf") ||
             (cNinf && s.confidence === "-inf")) &&
@@ -277,11 +278,12 @@ export const labelFilters = selectorFamily<LabelFilters, boolean>({
     }
     return filters;
   },
-  set: () => ({ get, set }, _) => {
+  set: () => ({ get, set, reset }, _) => {
     set(utils.activeModalFields, get(utils.activeFields));
     set(atoms.cropToContent(true), get(atoms.cropToContent(false)));
     set(filterAtoms.modalFilterStages, get(filterAtoms.filterStages));
-    set(atoms.colorByLabel(true), get(atoms.colorByLabel(false)));
+    reset(selectors.appConfigOption({ modal: true, key: "color_by_value" }));
+    reset(selectors.appConfigOption({ modal: true, key: "show_skeletons" }));
     set(atoms.colorSeed(true), get(atoms.colorSeed(false)));
     set(atoms.sortFilterResults(true), get(atoms.sortFilterResults(false)));
     set(atoms.alpha(true), get(atoms.alpha(false)));
diff --git a/app/packages/app/src/components/Filters/atoms.ts b/app/packages/app/src/components/Filters/atoms.ts
index f8fd2adbad3..d0849b1f537 100644
--- a/app/packages/app/src/components/Filters/atoms.ts
+++ b/app/packages/app/src/components/Filters/atoms.ts
@@ -1,3 +1,4 @@
+import { keys } from "@material-ui/core/styles/createBreakpoints";
 import { atom, selector, selectorFamily } from "recoil";
 import { v4 as uuid } from "uuid";
 
@@ -471,6 +472,14 @@ export const countsAtom = selectorFamily<
       filtered ? noneFilteredFieldCounts(modal) : noneFieldCounts(modal)
     )[path];
 
+    if (path.endsWith(".points.label")) {
+      const skeleton = get(selectors.skeleton(path.split(".")[0]));
+      return {
+        count: null,
+        results: skeleton ? skeleton.labels.map((l) => [l, null]) : [],
+      };
+    }
+
     const primitive = get(selectors.primitiveNames("sample"));
 
     if (modal && primitive.includes(path)) {
diff --git a/app/packages/app/src/components/Flashlight.tsx b/app/packages/app/src/components/Flashlight.tsx
index 13db9e1a4a6..f4603643c95 100644
--- a/app/packages/app/src/components/Flashlight.tsx
+++ b/app/packages/app/src/components/Flashlight.tsx
@@ -95,6 +95,11 @@ const flashlightLookerOptions = selector({
       timeZone: get(selectors.timeZone),
       alpha: get(atoms.alpha(false)),
       disabled: false,
+      showSkeletons: get(
+        selectors.appConfigOption({ key: "show_skeletons", modal: false })
+      ),
+      defaultSkeleton: get(atoms.stateDescription)?.dataset.default_skeleton,
+      skeletons: get(atoms.stateDescription)?.dataset.skeletons,
     };
   },
 });
diff --git a/app/packages/app/src/components/Looker.tsx b/app/packages/app/src/components/Looker.tsx
index dc2ccac4ffb..9e4ae510193 100644
--- a/app/packages/app/src/components/Looker.tsx
+++ b/app/packages/app/src/components/Looker.tsx
@@ -189,6 +189,16 @@ const KeypointInfo = ({ detail }) => {
   return (
     <AttrBlock style={{ borderColor: detail.color }}>
       <AttrInfo label={detail.label} />
+      {detail.point && (
+        <AttrInfo
+          label={Object.fromEntries(
+            detail.point.attributes.map(([k, v]) => [
+              `points[${detail.point.index}].${k}`,
+              v,
+            ])
+          )}
+        />
+      )}
     </AttrBlock>
   );
 };
@@ -352,6 +362,11 @@ const lookerOptions = selector({
       timeZone: get(selectors.timeZone),
       coloring: get(selectors.coloring(true)),
       alpha: get(atoms.alpha(true)),
+      showSkeletons: get(
+        selectors.appConfigOption({ key: "show_skeletons", modal: true })
+      ),
+      defaultSkeleton: get(atoms.stateDescription)?.dataset.default_skeleton,
+      skeletons: get(atoms.stateDescription)?.dataset.skeletons,
     };
   },
 });
diff --git a/app/packages/app/src/recoil/atoms.ts b/app/packages/app/src/recoil/atoms.ts
index a4e7ab355e1..32d2aad777c 100644
--- a/app/packages/app/src/recoil/atoms.ts
+++ b/app/packages/app/src/recoil/atoms.ts
@@ -141,11 +141,6 @@ export const viewCounter = atom({
   default: 0,
 });
 
-export const colorByLabel = atomFamily<boolean, boolean>({
-  key: "colorByLabel",
-  default: false,
-});
-
 export const DEFAULT_ALPHA = 0.7;
 
 export const alpha = atomFamily<number, boolean>({
diff --git a/app/packages/app/src/recoil/selectors.ts b/app/packages/app/src/recoil/selectors.ts
index 6e878f0cf02..2b4af389d3e 100644
--- a/app/packages/app/src/recoil/selectors.ts
+++ b/app/packages/app/src/recoil/selectors.ts
@@ -1,4 +1,9 @@
-import { selector, selectorFamily, SerializableParam } from "recoil";
+import {
+  atomFamily,
+  selector,
+  selectorFamily,
+  SerializableParam,
+} from "recoil";
 
 import * as atoms from "./atoms";
 import {
@@ -16,6 +21,7 @@ import { darkTheme } from "../shared/colors";
 import socket, { handleId, isNotebook, http } from "../shared/connection";
 import { Coloring, createColorGenerator, getRGB, RGB } from "@fiftyone/looker";
 import { getColor } from "@fiftyone/looker/src/color";
+import { KeypointSkeleton } from "@fiftyone/looker/src/state";
 
 export const isModalActive = selector<boolean>({
   key: "isModalActive",
@@ -33,6 +39,7 @@ export const deactivated = selector({
   key: "deactivated",
   get: ({ get }) => {
     const handle = handleId;
+
     const activeHandle = get(atoms.stateDescription)?.active_handle;
 
     const notebook = isNotebook;
@@ -652,10 +659,31 @@ export const appConfig = selector({
   },
 });
 
+export const appConfigDefault = selectorFamily<
+  boolean,
+  { key: string; modal: boolean }
+>({
+  key: "appConfigDefault",
+  get: ({ modal, key }) => ({ get }) => {
+    if (modal) {
+      return get(appConfigDefault({ modal: false, key }));
+    }
+
+    return get(appConfig)[key] || false;
+  },
+});
+export const appConfigOption = atomFamily<
+  boolean,
+  { key: string; modal: boolean }
+>({
+  key: "appConfigOptions",
+  default: appConfigDefault,
+});
+
 export const colorMap = selectorFamily<(val) => string, boolean>({
   key: "colorMap",
   get: (modal) => ({ get }) => {
-    const colorByLabel = get(atoms.colorByLabel(modal));
+    get(appConfigOption({ key: "color_by_value", modal }));
     let pool = get(atoms.colorPool);
     pool = pool.length ? pool : [darkTheme.brand];
     const seed = get(atoms.colorSeed(modal));
@@ -673,7 +701,7 @@ export const coloring = selectorFamily<Coloring, boolean>({
       seed,
       pool,
       scale: get(atoms.stateDescription).colorscale,
-      byLabel: get(atoms.colorByLabel(modal)),
+      byLabel: get(appConfigDefault({ key: "color_by_value", modal })),
       defaultMaskTargets: get(defaultTargets),
       maskTargets: get(targets).fields,
       targets: new Array(pool.length)
@@ -718,6 +746,16 @@ export const targets = selector({
   },
 });
 
+export const skeleton = selectorFamily<KeypointSkeleton | null, string>({
+  key: "skeleton",
+  get: (field) => ({ get }) => {
+    const dataset = get(atoms.stateDescription).dataset || {};
+    const skeletons = dataset.skeletons || {};
+
+    return skeletons[field] || dataset.default_skeleton || null;
+  },
+});
+
 export const getTarget = selector({
   key: "getTarget",
   get: ({ get }) => {
diff --git a/app/packages/looker/src/constants.ts b/app/packages/looker/src/constants.ts
index 1f9357cd19c..7c2133fb978 100644
--- a/app/packages/looker/src/constants.ts
+++ b/app/packages/looker/src/constants.ts
@@ -63,7 +63,7 @@ export const LABEL_TAGS_CLASSES = [
 export const LABEL_LISTS = {
   [CLASSIFICATIONS]: "classifications",
   [DETECTIONS]: "detections",
-  [KEYPOINTS]: "Keypoints",
+  [KEYPOINTS]: "keypoints",
   [POLYLINES]: "polylines",
   [TEMPORAL_DETECTIONS]: "detections",
 };
diff --git a/app/packages/looker/src/overlays/base.ts b/app/packages/looker/src/overlays/base.ts
index a0676229b13..a0442111681 100644
--- a/app/packages/looker/src/overlays/base.ts
+++ b/app/packages/looker/src/overlays/base.ts
@@ -25,7 +25,11 @@ export interface PointInfo<Label extends BaseLabel> {
   color: string;
   field: string;
   label: Label;
-  point?: Coordinates;
+  point?: {
+    index: number;
+    attributes: [string, unknown][];
+    coordinates: Coordinates;
+  };
   target?: number;
   type: string;
 }
diff --git a/app/packages/looker/src/overlays/keypoint.ts b/app/packages/looker/src/overlays/keypoint.ts
index c8a01a3de91..92b6d76b130 100644
--- a/app/packages/looker/src/overlays/keypoint.ts
+++ b/app/packages/looker/src/overlays/keypoint.ts
@@ -3,8 +3,8 @@
  */
 
 import { INFO_COLOR, TOLERANCE } from "../constants";
-import { BaseState, Coordinates } from "../state";
-import { distance } from "../util";
+import { BaseState, Coordinates, KeypointSkeleton } from "../state";
+import { distance, distanceFromLineSegment, multiply } from "../util";
 import { CONTAINS, CoordinateOverlay, PointInfo, RegularLabel } from "./base";
 import { t } from "./util";
 
@@ -20,7 +20,7 @@ export default class KeypointOverlay<
   }
 
   containsPoint(state: Readonly<State>): CONTAINS {
-    if (this.getDistanceAndPoint(state)[0] <= state.pointRadius) {
+    if (this.getDistanceAndMaybePoint(state)[0] <= state.pointRadius) {
       return CONTAINS.BORDER;
     }
     return CONTAINS.NONE;
@@ -31,7 +31,20 @@ export default class KeypointOverlay<
     const selected = this.isSelected(state);
     ctx.lineWidth = 0;
 
-    for (const point of this.label.points) {
+    const skeleton = getSkeleton(this.field, state);
+    if (skeleton && state.options.showSkeletons) {
+      for (let i = 0; i < skeleton.edges.length; i++) {
+        const path = skeleton.edges[i].map((index) => this.label.points[index]);
+        this.strokePath(ctx, state, path, color);
+
+        if (selected) {
+          this.strokePath(ctx, state, path, INFO_COLOR, state.dashLength);
+        }
+      }
+    }
+
+    for (let i = 0; i < this.label.points.length; i++) {
+      const point = this.label.points[i];
       ctx.fillStyle = color;
       ctx.beginPath();
       const [x, y] = t(state, ...point);
@@ -54,15 +67,29 @@ export default class KeypointOverlay<
   }
 
   getMouseDistance(state: Readonly<State>): number {
-    return this.getDistanceAndPoint(state)[0];
+    return this.getDistanceAndMaybePoint(state)[0];
   }
 
   getPointInfo(state: Readonly<State>): PointInfo<KeypointLabel> {
+    const point = this.getDistanceAndMaybePoint(state)[1];
+    const skeleton = getSkeleton(this.field, state);
     return {
       color: this.getColor(state),
       field: this.field,
       label: this.label,
-      point: this.getDistanceAndPoint(state)[1],
+      point:
+        point !== null
+          ? {
+              coordinates: this.label.points[point],
+              attributes: Object.entries(this.label)
+                .filter(([k, v]) => Array.isArray(v) && k !== "tags")
+                .map(([k, v]) => [k, v[point]])
+                .concat(
+                  skeleton ? [["label", skeleton.labels[point]]] : []
+                ) as [string, unknown][],
+              index: point,
+            }
+          : null,
       type: "Keypoint",
     };
   }
@@ -71,25 +98,70 @@ export default class KeypointOverlay<
     return getKeypointPoints([this.label]);
   }
 
-  private getDistanceAndPoint(state: Readonly<State>) {
-    const distances = [];
+  private getDistanceAndMaybePoint(
+    state: Readonly<State>
+  ): [number, number | null] {
+    const distances: [number, number][] = [];
     let {
-      canvasBBox: [_, __, w, h],
+      config: { dimensions },
       pointRadius,
-      relativeCoordinates: [x, y],
+      pixelCoordinates: [x, y],
     } = state;
     pointRadius = this.isSelected(state) ? pointRadius * 2 : pointRadius;
-    for (const [px, py] of this.label.points) {
-      const d = distance(x * w, y * h, px * w, py * h);
+    for (let i = 0; i < this.label.points.length; i++) {
+      const point = this.label.points[i];
+      const d = distance(
+        x,
+        y,
+        ...(multiply(dimensions, point) as [number, number])
+      );
       if (d <= pointRadius * TOLERANCE) {
-        distances.push([0, [px, py]]);
+        distances.push([0, i]);
       } else {
-        distances.push([d, [px, py]]);
+        distances.push([d, i]);
+      }
+    }
+
+    const skeleton = getSkeleton(this.field, state);
+
+    if (skeleton) {
+      for (let i = 0; i < skeleton.edges.length; i++) {
+        const path = skeleton.edges[i].map((index) => this.label.points[index]);
+
+        for (let j = 1; j < path.length; j++) {
+          distances.push([
+            distanceFromLineSegment(
+              [x, y],
+              multiply(dimensions, path[j - 1]),
+              multiply(dimensions, path[j])
+            ),
+            null,
+          ]);
+        }
       }
     }
 
     return distances.sort((a, b) => a[0] - b[0])[0];
   }
+
+  private strokePath(
+    ctx: CanvasRenderingContext2D,
+    state: Readonly<State>,
+    path: Coordinates[],
+    color: string,
+    dash?: number
+  ) {
+    ctx.beginPath();
+    ctx.lineWidth = state.strokeWidth;
+    ctx.strokeStyle = color;
+    ctx.setLineDash(dash ? [dash] : []);
+    ctx.moveTo(...t(state, path[0][0], path[0][1]));
+    for (const [x, y] of path.slice(1)) {
+      ctx.lineTo(...t(state, x, y));
+    }
+
+    ctx.stroke();
+  }
 }
 
 export const getKeypointPoints = (labels: KeypointLabel[]): Coordinates[] => {
@@ -99,3 +171,16 @@ export const getKeypointPoints = (labels: KeypointLabel[]): Coordinates[] => {
   });
   return points;
 };
+
+const getSkeleton = (
+  name: string,
+  state: BaseState
+): KeypointSkeleton | null => {
+  const defaultSkeleton = state.options.defaultSkeleton;
+
+  const namedSkeleton = state.options.skeletons
+    ? state.options.skeletons[name]
+    : null;
+
+  return namedSkeleton || defaultSkeleton || null;
+};
diff --git a/app/packages/looker/src/state.ts b/app/packages/looker/src/state.ts
index 203b92f96f3..099202ff463 100644
--- a/app/packages/looker/src/state.ts
+++ b/app/packages/looker/src/state.ts
@@ -68,6 +68,11 @@ export interface ControlMap<State extends BaseState> {
   [key: string]: Control<State>;
 }
 
+export interface KeypointSkeleton {
+  labels: string[];
+  edges: number[][];
+}
+
 interface BaseOptions {
   activePaths: string[];
   filter: {
@@ -95,6 +100,9 @@ interface BaseOptions {
   timeZone: string;
   mimetype: string;
   alpha: number;
+  defaultSkeleton?: KeypointSkeleton;
+  skeletons: { [key: string]: KeypointSkeleton };
+  showSkeletons: boolean;
 }
 
 export type BoundingBox = [number, number, number, number];
@@ -284,6 +292,9 @@ const DEFAULT_BASE_OPTIONS: BaseOptions = {
   timeZone: "UTC",
   mimetype: "",
   alpha: 0.7,
+  defaultSkeleton: null,
+  skeletons: {},
+  showSkeletons: true,
 };
 
 export const DEFAULT_FRAME_OPTIONS: FrameOptions = {
diff --git a/app/packages/looker/src/util.ts b/app/packages/looker/src/util.ts
index 2a645f7d54c..f14a92986f7 100644
--- a/app/packages/looker/src/util.ts
+++ b/app/packages/looker/src/util.ts
@@ -80,6 +80,13 @@ export function dot2d(ax: number, ay: number, bx: number, by: number): number {
   return ax * bx + ay * by;
 }
 
+/**
+ * Elementwise vector multiplication
+ */
+export function multiply<T extends number[]>(one: T, two: T): T {
+  return one.map((i, j) => i * two[j]) as T;
+}
+
 /**
  * Projects a point onto a line defined by two other points.
  */
@@ -398,6 +405,9 @@ export const mergeUpdates = <State extends BaseState>(
     if (n === null) {
       return n;
     }
+    if (o === null) {
+      return n;
+    }
     return mergeWith(merger, o, n);
   };
   return mergeWith(merger, state, updates);
diff --git a/docs/source/_includes/substitutions.rst b/docs/source/_includes/substitutions.rst
index 8de32a2fab4..ea21e0d3c85 100644
--- a/docs/source/_includes/substitutions.rst
+++ b/docs/source/_includes/substitutions.rst
@@ -58,6 +58,8 @@
 .. |CategoricalAttribute| replace:: :class:`CategoricalAttribute <fiftyone.core.labels.CategoricalAttribute>`
 .. |NumericAttribute| replace:: :class:`NumericAttribute <fiftyone.core.labels.NumericAttribute>`
 
+.. |KeypointSkeleton| replace:: :class:`KeypointSkeleton <fiftyone.core.odm.dataset.KeypointSkeleton>`
+
 .. |tags| replace:: :class:`tags <fiftyone.core.sample.Sample>`
 .. |Tags| replace:: :class:`Tags <fiftyone.core.sample.Sample>`
 
diff --git a/docs/source/user_guide/config.rst b/docs/source/user_guide/config.rst
index abd75da16bd..7bde552590e 100644
--- a/docs/source/user_guide/config.rst
+++ b/docs/source/user_guide/config.rst
@@ -459,6 +459,8 @@ The FiftyOne App can be configured in the ways described below:
 +---------------------------+----------------------------------------+-----------------------------+------------------------------------------------------------------------------------------+
 | Config field              | Environment variable                   | Default value               | Description                                                                              |
 +===========================+========================================+=============================+==========================================================================================+
+| `color_by_value`          | `FIFTYONE_APP_COLOR_BY_VALUE`          | `False`                     | Whether to color labels by their label string value or their field name                  |
++---------------------------+----------------------------------------+-----------------------------+------------------------------------------------------------------------------------------+
 | `color_pool`              | `FIFTYONE_APP_COLOR_POOL`              | See below                   | A list of browser supported color strings from which the App should draw from when       |
 |                           |                                        |                             | drawing labels (e.g., object bounding boxes).                                            |
 +---------------------------+----------------------------------------+-----------------------------+------------------------------------------------------------------------------------------+
@@ -479,6 +481,8 @@ The FiftyOne App can be configured in the ways described below:
 | `show_label`              | `FIFTYONE_APP_SHOW_LABEL`              | `True`                      | Whether to show the label value when rendering detection labels in the App's expanded    |
 |                           |                                        |                             | sample view.                                                                             |
 +---------------------------+----------------------------------------+-----------------------------+------------------------------------------------------------------------------------------+
+| `show_skeletons           | `FIFTYONE_APP_SHOW_SKELETONS`          | `True`                      | Whether to show keypoint skeletons if one is defined                                     |
++---------------------------+----------------------------------------+-----------------------------+------------------------------------------------------------------------------------------+
 | `show_tooltip`            | `FIFTYONE_APP_SHOW_TOOLTIP`            | `True`                      | Whether to show the tooltip when hovering over labels in the App's expanded sample view. |
 +---------------------------+----------------------------------------+-----------------------------+------------------------------------------------------------------------------------------+
 | `use_frame_number`        | `FIFTYONE_APP_USE_FRAME_NUMBER`        | `False`                     | Whether to use the frame number instead of a timestamp in the expanded sample view. Only |
@@ -507,26 +511,32 @@ You can print your App config at any time via the Python library and the CLI:
     .. code-block:: text
 
         {
+            "color_by_value": false,
             "color_pool": [
                 "#ee0000",
+                "#ee6600",
+                "#993300",
+                "#996633",
                 "#999900",
                 "#009900",
                 "#003300",
                 "#009999",
                 "#000099",
-                "#6600ff",
-                "#ee6600",
-                "#993300",
-                "#996633",
                 "#0066ff",
+                "#6600ff",
                 "#cc33cc",
                 "#777799"
             ],
             "colorscale": "viridis",
             "grid_zoom": 5,
+            "loop_videos": false,
             "notebook_height": 800,
             "show_confidence": true,
-            "show_attributes": true
+            "show_index": true,
+            "show_label": true,
+            "show_skeletons": true,
+            "show_tooltip": true,
+            "use_frame_number": false
         }
 
         True
@@ -544,26 +554,32 @@ You can print your App config at any time via the Python library and the CLI:
     .. code-block:: text
 
         {
+            "color_by_value": false,
             "color_pool": [
                 "#ee0000",
+                "#ee6600",
+                "#993300",
+                "#996633",
                 "#999900",
                 "#009900",
                 "#003300",
                 "#009999",
                 "#000099",
-                "#6600ff",
-                "#ee6600",
-                "#993300",
-                "#996633",
                 "#0066ff",
+                "#6600ff",
                 "#cc33cc",
                 "#777799"
             ],
             "colorscale": "viridis",
             "grid_zoom": 5,
+            "loop_videos": false,
             "notebook_height": 800,
             "show_confidence": true,
-            "show_attributes": true
+            "show_index": true,
+            "show_label": true,
+            "show_skeletons": true,
+            "show_tooltip": true,
+            "use_frame_number": false
         }
 
         True
diff --git a/docs/source/user_guide/dataset_creation/datasets.rst b/docs/source/user_guide/dataset_creation/datasets.rst
index 4a37323b3b3..c4239b72bf8 100644
--- a/docs/source/user_guide/dataset_creation/datasets.rst
+++ b/docs/source/user_guide/dataset_creation/datasets.rst
@@ -5625,52 +5625,25 @@ The
 :meth:`has_dataset_info <fiftyone.utils.data.importers.DatasetImporter.has_dataset_info>`
 property of the importer allows it to declare whether its
 :meth:`get_dataset_info() <fiftyone.utils.data.importers.DatasetImporter.get_dataset_info>`
-method should be called after all samples have been imported to retrieve
-dataset-level information to store in the relevant properties of the FiftyOne
-dataset, including
-:meth:`info <fiftyone.core.dataset.Dataset.info>`,
-:meth:`classes <fiftyone.core.dataset.Dataset.classes>`,
-:meth:`default_classes <fiftyone.core.dataset.Dataset.default_classes>`,
-:meth:`mask_targets <fiftyone.core.dataset.Dataset.mask_targets>`, and
-:meth:`default_mask_targets <fiftyone.core.dataset.Dataset.default_mask_targets>`.
-
-The function below describes how the `info` dict is dissected by the dataset
-import routine:
-
-.. code-block:: python
-
-    def parse_info(dataset, info):
-        """Parses the info returned by :meth:`DatasetImporter.get_dataset_info` and
-        stores it on the relevant properties of the dataset.
-
-        Args:
-            dataset: a :class:`fiftyone.core.dataset.Dataset`
-            info: an info dict
-        """
-        classes = info.pop("classes", None)
-        if isinstance(classes, dict):
-            # Classes may already exist, so update rather than set
-            dataset.classes.update(classes)
-        elif isinstance(classes, list):
-            dataset.default_classes = classes
-
-        default_classes = info.pop("default_classes", None)
-        if default_classes:
-            dataset.default_classes = default_classes
-
-        mask_targets = info.pop("mask_targets", None)
-        if mask_targets:
-            # Mask targets may already exist, so update rather than set
-            dataset.mask_targets.update(dataset._parse_mask_targets(mask_targets))
-
-        default_mask_targets = info.pop("default_mask_targets", None)
-        if default_mask_targets:
-            dataset.default_mask_targets = dataset._parse_default_mask_targets(
-                default_mask_targets
-            )
-
-        dataset.info.update(info)
-        dataset.save()
+method should be called after all samples have been imported to retrieve a dict
+of dataset-level information to store in the
+:meth:`info <fiftyone.core.dataset.Dataset.info>` property of the dataset.
+
+As a special case, if the `info` dict contains any of the keys listed below,
+these items are popped and stored in the corresponding dedicated dataset field:
+
+-   `"classes"` key:
+    :meth:`Dataset.classes <fiftyone.core.dataset.Dataset.classes>`
+-   `"default_classes"` key:
+    :meth:`Dataset.default_classes <fiftyone.core.dataset.Dataset.default_classes>`
+-   `"mask_targets"` key:
+    :meth:`Dataset.mask_targets <fiftyone.core.dataset.Dataset.mask_targets>`
+-   `"default_mask_targets"` key:
+    :meth:`Dataset.default_mask_targets <fiftyone.core.dataset.Dataset.default_mask_targets>`
+-   `"skeletons"` key:
+    :meth:`Dataset.skeletons <fiftyone.core.dataset.Dataset.skeletons>`
+-   `"default_skeleton"` key:
+    :meth:`Dataset.default_skeleton <fiftyone.core.dataset.Dataset.default_skeleton>`
 
 .. _writing-a-custom-dataset-type-importer:
 
diff --git a/docs/source/user_guide/using_datasets.rst b/docs/source/user_guide/using_datasets.rst
index f65f633080d..267062239a0 100644
--- a/docs/source/user_guide/using_datasets.rst
+++ b/docs/source/user_guide/using_datasets.rst
@@ -310,6 +310,72 @@ require knowledge of the mask targets for a dataset or field(s).
     :meth:`default_mask_targets <fiftyone.core.dataset.Dataset.default_mask_targets>`
     properties to save the changes to the database.
 
+.. _storing-keypoint-skeletons:
+
+Storing keypoint skeletons
+--------------------------
+
+All |Dataset| instances have
+:meth:`skeletons <fiftyone.core.dataset.Dataset.skeletons>` and
+:meth:`default_skeletons <fiftyone.core.dataset.Dataset.default_skeletons>`
+properties that you can use to store keypoint skeletons for |Keypoint| field(s)
+of a dataset.
+
+The :meth:`skeletons <fiftyone.core.dataset.Dataset.skeletons>` property is a
+dictionary mapping field names to |KeypointSkeleton| instances, each of which
+defines the keypoint label strings and edge connectivity for the |Keypoint|
+instances in the specified field of the dataset.
+
+If all |Keypoint| fields in your dataset have the same semantics, you can store
+a single |KeypointSkeleton| in the
+:meth:`default_skeleton <fiftyone.core.dataset.Dataset.default_skeleton>`
+property of your dataset.
+
+When you load datasets with |Keypoint| fields in the App that have
+corresponding skeletons, the skeletons will automatically be rendered and label
+strings will appear in the App's tooltip when you hover over the keypoints.
+
+.. note::
+
+    When using keypoint skeletons, each |Keypoint| instance's
+    :attr:`points <fiftyone.core.labels.Keypoint.points>` list must always
+    respect the indexing defined by the field's |KeypointSkeleton|.
+
+    If a particular keypoint is occluded or missing for an object, use
+    `[float("nan"), float("nan")]` in its
+    :attr:`points <fiftyone.core.labels.Keypoint.points>` list.
+
+.. code-block:: python
+    :linenos:
+
+    import fiftyone as fo
+
+    dataset = fo.Dataset()
+
+    # Set keypoint skeleton for the `ground_truth` field
+    dataset.skeletons = {
+        "ground_truth": fo.KeypointSkeleton(
+            labels=[
+                "left hand" "left shoulder", "right shoulder", "right hand",
+                "left eye", "right eye", "mouth",
+            ],
+            edges=[[0, 1, 2, 3], [4, 5, 6]],
+        )
+    }
+
+    # Edit an existing skeleton
+    dataset.skeletons["ground_truth"].labels[-1] = "lips"
+    dataset.save()  # must save after edits
+
+.. note::
+
+    You must call
+    :meth:`dataset.save() <fiftyone.core.dataset.Dataset.save>` after updating
+    the dataset's
+    :meth:`mask_targets <fiftyone.core.dataset.Dataset.mask_targets>` and
+    :meth:`default_mask_targets <fiftyone.core.dataset.Dataset.default_mask_targets>`
+    properties to save the changes to the database.
+
 Deleting a dataset
 ------------------
 
@@ -1784,8 +1850,11 @@ dynamically adding new fields to each |Keypoint| instance:
 
 .. note::
 
-    Did you know? You can view custom attributes in the
-    :ref:`App tooltip <app-sample-view>` by hovering over the objects.
+    Did you know? You can
+    :ref:`store keypoint skeletons <storing-keypoint-skeletons>` for your
+    keypoint fields on your dataset. Then, when you view the dataset in the
+    App, label strings and edges will be drawn when you visualize these fields
+    in the App.
 
 .. _semantic-segmentation:
 
diff --git a/fiftyone/__public__.py b/fiftyone/__public__.py
index e5a23068dcf..ba4890ade7e 100644
--- a/fiftyone/__public__.py
+++ b/fiftyone/__public__.py
@@ -112,6 +112,7 @@
     ModelManagerConfig,
     ModelManager,
 )
+from .core.odm import KeypointSkeleton
 from .core.plots import (
     plot_confusion_matrix,
     plot_pr_curve,
diff --git a/fiftyone/core/collections.py b/fiftyone/core/collections.py
index 3f2b256f5dc..aca1e15ba90 100644
--- a/fiftyone/core/collections.py
+++ b/fiftyone/core/collections.py
@@ -286,6 +286,68 @@ def get_mask_targets(self, field):
 
         return None
 
+    @property
+    def skeletons(self):
+        """The keypoint skeletons of the underlying dataset.
+
+        See :meth:`fiftyone.core.dataset.Dataset.skeletons` for more
+        information.
+        """
+        raise NotImplementedError("Subclass must implement skeletons")
+
+    @skeletons.setter
+    def skeletons(self, skeletons):
+        raise NotImplementedError("Subclass must implement skeletons")
+
+    @property
+    def default_skeleton(self):
+        """The default keypoint skeleton of the underlying dataset.
+
+        See :meth:`fiftyone.core.dataset.Dataset.default_skeleton` for more
+        information.
+        """
+        raise NotImplementedError("Subclass must implement default_skeleton")
+
+    @default_skeleton.setter
+    def default_skeleton(self, skeleton):
+        raise NotImplementedError("Subclass must implement default_skeleton")
+
+    def has_skeleton(self, field):
+        """Determines whether this collection has a keypoint skeleton for the
+        given field.
+
+        Keypoint skeletons may be defined either in :meth:`skeletons` or
+        :meth:`default_skeleton`.
+
+        Args:
+            field: a field name
+
+        Returns:
+            True/False
+        """
+        return field in self.skeletons or bool(self.default_skeleton)
+
+    def get_skeleton(self, field):
+        """Gets the keypoint skeleton for the given field, or None if no
+        skeleton is available.
+
+        Skeletons are first retrieved from :meth:`skeletons` if they exist,
+        otherwise from :meth:`default_skeleton`.
+
+        Args:
+            field: a field name
+
+        Returns:
+            a list of classes, or None
+        """
+        if field in self.skeletons:
+            return self.skeletons[field]
+
+        if self.default_skeleton:
+            return self.default_skeleton
+
+        return None
+
     def summary(self):
         """Returns a string summary of the collection.
 
@@ -5882,7 +5944,12 @@ def values(
         return self._make_and_aggregate(make, field_or_expr)
 
     def draw_labels(
-        self, output_dir, label_fields=None, overwrite=False, config=None,
+        self,
+        output_dir,
+        label_fields=None,
+        overwrite=False,
+        config=None,
+        **kwargs,
     ):
         """Renders annotated versions of the media in the collection with the
         specified label data overlaid to the given directory.
@@ -5896,13 +5963,17 @@ def draw_labels(
 
         Args:
             output_dir: the directory to write the annotated media
-            label_fields (None): a list of label fields to render. By default,
-                all :class:`fiftyone.core.labels.Label` fields are drawn
+            label_fields (None): a label field or list of label fields to
+                render. By default, all :class:`fiftyone.core.labels.Label`
+                fields are drawn
             overwrite (False): whether to delete ``output_dir`` if it exists
                 before rendering
             config (None): an optional
                 :class:`fiftyone.utils.annotations.DrawConfig` configuring how
                 to draw the labels
+            **kwargs: optional keyword arguments specifying parameters of the
+                default :class:`fiftyone.utils.annotations.DrawConfig` to
+                override
 
         Returns:
             the list of paths to the rendered media
@@ -5922,11 +5993,19 @@ def draw_labels(
 
         if self.media_type == fom.VIDEO:
             return foua.draw_labeled_videos(
-                self, output_dir, label_fields=label_fields, config=config
+                self,
+                output_dir,
+                label_fields=label_fields,
+                config=config,
+                **kwargs,
             )
 
         return foua.draw_labeled_images(
-            self, output_dir, label_fields=label_fields, config=config
+            self,
+            output_dir,
+            label_fields=label_fields,
+            config=config,
+            **kwargs,
         )
 
     def export(
@@ -6683,6 +6762,12 @@ def to_dict(self, rel_dir=None, frame_labels_dir=None, pretty_print=False):
         if self.default_mask_targets:
             d["default_mask_targets"] = self._serialize_default_mask_targets()
 
+        if self.skeletons:
+            d["skeletons"] = self._serialize_skeletons()
+
+        if self.default_skeleton:
+            d["default_skeleton"] = self._serialize_default_skeleton()
+
         # Serialize samples
         samples = []
         for sample in self.iter_samples(progress=True):
@@ -7088,6 +7173,26 @@ def _parse_default_mask_targets(self, default_mask_targets):
             "default_mask_targets", default_mask_targets
         )
 
+    def _serialize_skeletons(self):
+        return self._root_dataset._doc.field_to_mongo("skeletons")
+
+    def _serialize_default_skeleton(self):
+        return self._root_dataset._doc.field_to_mongo("default_skeleton")
+
+    def _parse_skeletons(self, skeletons):
+        if not skeletons:
+            return skeletons
+
+        return self._root_dataset._doc.field_to_python("skeletons", skeletons)
+
+    def _parse_default_skeleton(self, default_skeleton):
+        if not default_skeleton:
+            return default_skeleton
+
+        return self._root_dataset._doc.field_to_python(
+            "default_skeleton", default_skeleton
+        )
+
     def _to_fields_str(self, field_schema):
         max_len = max([len(field_name) for field_name in field_schema]) + 1
         return "\n".join(
diff --git a/fiftyone/core/config.py b/fiftyone/core/config.py
index 174c4634cec..826ae305e15 100644
--- a/fiftyone/core/config.py
+++ b/fiftyone/core/config.py
@@ -92,7 +92,10 @@ def __init__(self, d=None):
             d, "model_zoo_dir", env_var="FIFTYONE_MODEL_ZOO_DIR", default=None
         )
         self.module_path = self.parse_string_array(
-            d, "module_path", env_var="FIFTYONE_MODULE_PATH", default=None,
+            d,
+            "module_path",
+            env_var="FIFTYONE_MODULE_PATH",
+            default=None,
         )
         self.dataset_zoo_manifest_paths = self.parse_path_array(
             d,
@@ -155,7 +158,10 @@ def __init__(self, d=None):
             default=None,
         )
         self.desktop_app = self.parse_bool(
-            d, "desktop_app", env_var="FIFTYONE_DESKTOP_APP", default=False,
+            d,
+            "desktop_app",
+            env_var="FIFTYONE_DESKTOP_APP",
+            default=False,
         )
         self._show_progress_bars = None  # declare
         self.show_progress_bars = self.parse_bool(
@@ -165,7 +171,10 @@ def __init__(self, d=None):
             default=True,
         )
         self.do_not_track = self.parse_bool(
-            d, "do_not_track", env_var="FIFTYONE_DO_NOT_TRACK", default=False,
+            d,
+            "do_not_track",
+            env_var="FIFTYONE_DO_NOT_TRACK",
+            default=False,
         )
         self.requirement_error_level = self.parse_int(
             d,
@@ -253,6 +262,12 @@ def __init__(self, d=None):
         if d is None:
             d = {}
 
+        self.color_by_value = self.parse_bool(
+            d,
+            "color_by_value",
+            env_var="FIFTYONE_APP_COLOR_BY_VALUE",
+            default=False,
+        )
         self.color_pool = self.parse_string_array(
             d,
             "color_pool",
@@ -287,10 +302,22 @@ def __init__(self, d=None):
             default=True,
         )
         self.show_index = self.parse_bool(
-            d, "show_index", env_var="FIFTYONE_APP_SHOW_INDEX", default=True,
+            d,
+            "show_index",
+            env_var="FIFTYONE_APP_SHOW_INDEX",
+            default=True,
         )
         self.show_label = self.parse_bool(
-            d, "show_label", env_var="FIFTYONE_APP_SHOW_LABEL", default=True,
+            d,
+            "show_label",
+            env_var="FIFTYONE_APP_SHOW_LABEL",
+            default=True,
+        )
+        self.show_skeletons = self.parse_bool(
+            d,
+            "show_skeletons",
+            env_var="FIFTYONE_APP_SHOW_SKELETONS",
+            default=True,
         )
         self.show_tooltip = self.parse_bool(
             d,
diff --git a/fiftyone/core/dataset.py b/fiftyone/core/dataset.py
index 16ceafd388e..78a3f4359ce 100644
--- a/fiftyone/core/dataset.py
+++ b/fiftyone/core/dataset.py
@@ -557,6 +557,74 @@ def default_mask_targets(self, targets):
         self._doc.default_mask_targets = targets
         self.save()
 
+    @property
+    def skeletons(self):
+        """A dict mapping field names to keypoint skeletons, each of which
+        defines the semantic labels and point connectivity for the
+        :class:`fiftyone.core.labels.Keypoint` instances in the corresponding
+        field of the dataset.
+
+        Examples::
+
+            import fiftyone as fo
+
+            dataset = fo.Dataset()
+
+            # Set keypoint skeleton for the `ground_truth` field
+            dataset.skeletons = {
+                "ground_truth": fo.KeypointSkeleton(
+                    labels=[
+                        "left hand" "left shoulder", "right shoulder", "right hand",
+                        "left eye", "right eye", "mouth",
+                    ],
+                    edges=[[0, 1, 2, 3], [4, 5, 6]],
+                )
+            }
+
+            # Edit an existing skeleton
+            dataset.skeletons["ground_truth"].labels[-1] = "lips"
+            dataset.save()  # must save after edits
+        """
+        return self._doc.skeletons
+
+    @skeletons.setter
+    def skeletons(self, skeletons):
+        self._doc.skeletons = skeletons
+        self.save()
+
+    @property
+    def default_skeleton(self):
+        """A default keypoint skeleton defining the semantic labels and point
+        connectivity for all :class:`fiftyone.core.labels.Keypoint` fields of
+        this dataset that do not have customized skeletons defined in
+        :meth:`skeleton`.
+
+        Examples::
+
+            import fiftyone as fo
+
+            dataset = fo.Dataset()
+
+            # Set default keypoint skeleton
+            dataset.default_skeleton = fo.KeypointSkeleton(
+                labels=[
+                    "left hand" "left shoulder", "right shoulder", "right hand",
+                    "left eye", "right eye", "mouth",
+                ],
+                edges=[[0, 1, 2, 3], [4, 5, 6]],
+            )
+
+            # Edit the default skeleton
+            dataset.default_skeleton.labels[-1] = "lips"
+            dataset.save()  # must save after edits
+        """
+        return self._doc.default_skeleton
+
+    @default_skeleton.setter
+    def default_skeleton(self, skeleton):
+        self._doc.default_skeleton = skeleton
+        self.save()
+
     @property
     def deleted(self):
         """Whether the dataset is deleted."""
@@ -4115,6 +4183,11 @@ def from_dict(cls, d, name=None, rel_dir=None, frame_labels_dir=None):
             d.get("default_mask_targets", {})
         )
 
+        dataset.skeletons = dataset._parse_skeletons(d.get("skeletons", {}))
+        dataset.default_skeleton = dataset._parse_default_skeleton(
+            d.get("default_skeleton", None)
+        )
+
         def parse_sample(sd):
             if rel_dir and not os.path.isabs(sd["filepath"]):
                 sd["filepath"] = os.path.join(rel_dir, sd["filepath"])
@@ -5076,16 +5149,21 @@ def _merge_dataset_doc(
         curr_doc.info.update(doc.info)
         curr_doc.classes.update(doc.classes)
         curr_doc.mask_targets.update(doc.mask_targets)
+        curr_doc.skeletons.update(doc.skeletons)
 
         if doc.default_classes:
             curr_doc.default_classes = doc.default_classes
 
         if doc.default_mask_targets:
             curr_doc.default_mask_targets = doc.default_mask_targets
+
+        if doc.default_skeleton:
+            curr_doc.default_skeleton = doc.default_skeleton
     else:
         _update_no_overwrite(curr_doc.info, doc.info)
         _update_no_overwrite(curr_doc.classes, doc.classes)
         _update_no_overwrite(curr_doc.mask_targets, doc.mask_targets)
+        _update_no_overwrite(curr_doc.skeletons, doc.skeletons)
 
         if doc.default_classes and not curr_doc.default_classes:
             curr_doc.default_classes = doc.default_classes
@@ -5093,6 +5171,9 @@ def _merge_dataset_doc(
         if doc.default_mask_targets and not curr_doc.default_mask_targets:
             curr_doc.default_mask_targets = doc.default_mask_targets
 
+        if doc.default_skeleton and not curr_doc.default_skeleton:
+            curr_doc.default_skeleton = doc.default_skeleton
+
     curr_doc.save()
 
     if dataset:
diff --git a/fiftyone/core/fields.py b/fiftyone/core/fields.py
index 383dae60cd2..729a3186b22 100644
--- a/fiftyone/core/fields.py
+++ b/fiftyone/core/fields.py
@@ -567,7 +567,7 @@ def validate(self, value):
 class ClassesField(ListField):
     """A :class:`ListField` that stores class label strings.
 
-    If this field is not set, its default value is ``{}``.
+    If this field is not set, its default value is ``[]``.
     """
 
     def __init__(self, **kwargs):
diff --git a/fiftyone/core/labels.py b/fiftyone/core/labels.py
index 7beaecbcf1d..1d30115cd37 100644
--- a/fiftyone/core/labels.py
+++ b/fiftyone/core/labels.py
@@ -851,7 +851,7 @@ class Keypoint(_HasID, _HasAttributesDict, Label):
     Args:
         label (None): a label for the points
         points (None): a list of ``(x, y)`` keypoints in ``[0, 1] x [0, 1]``
-        confidence (None): a confidence in ``[0, 1]`` for the points
+        confidence (None): a list of confidences in ``[0, 1]`` for each point
         index (None): an index for the keypoints
         attributes ({}): a dict mapping attribute names to :class:`Attribute`
             instances
@@ -859,7 +859,7 @@ class Keypoint(_HasID, _HasAttributesDict, Label):
 
     label = fof.StringField()
     points = fof.KeypointsField()
-    confidence = fof.FloatField()
+    confidence = fof.ListField(fof.FloatField(), null=True)
     index = fof.IntField()
 
     def to_shapely(self, frame_size=None):
diff --git a/fiftyone/core/odm/__init__.py b/fiftyone/core/odm/__init__.py
index 8f53456ce11..76ed0111a30 100644
--- a/fiftyone/core/odm/__init__.py
+++ b/fiftyone/core/odm/__init__.py
@@ -36,6 +36,7 @@
 from .dataset import (
     create_field,
     SampleFieldDocument,
+    KeypointSkeleton,
     DatasetDocument,
 )
 from .document import (
diff --git a/fiftyone/core/odm/dataset.py b/fiftyone/core/odm/dataset.py
index 25206ddde4a..71dfcb71909 100644
--- a/fiftyone/core/odm/dataset.py
+++ b/fiftyone/core/odm/dataset.py
@@ -17,6 +17,7 @@
     DictField,
     EmbeddedDocumentField,
     EmbeddedDocumentListField,
+    IntField,
     ListField,
     StringField,
     TargetsField,
@@ -183,6 +184,19 @@ def _get_attr_repr(field, attr_name):
         return etau.get_class_name(attr) if attr else None
 
 
+class KeypointSkeleton(EmbeddedDocument):
+    """Description of a keypoint skeleton.
+
+    Args:
+        labels (None): a list of keypoint label strings
+        edges: a list of lists of integer indexes defining the connectivity
+            between keypoints
+    """
+
+    labels = ListField(StringField(), null=True)
+    edges = ListField(ListField(IntField()))
+
+
 class DatasetDocument(Document):
     """Backing document for datasets."""
 
@@ -201,6 +215,10 @@ class DatasetDocument(Document):
     default_classes = ClassesField()
     mask_targets = DictField(TargetsField())
     default_mask_targets = TargetsField()
+    skeletons = DictField(
+        EmbeddedDocumentField(document_type=KeypointSkeleton)
+    )
+    default_skeleton = EmbeddedDocumentField(document_type=KeypointSkeleton)
     sample_fields = EmbeddedDocumentListField(
         document_type=SampleFieldDocument
     )
diff --git a/fiftyone/core/view.py b/fiftyone/core/view.py
index 28ba2f77cac..19292f36f41 100644
--- a/fiftyone/core/view.py
+++ b/fiftyone/core/view.py
@@ -198,6 +198,22 @@ def default_mask_targets(self):
     def default_mask_targets(self, targets):
         self._root_dataset.default_mask_targets = targets
 
+    @property
+    def skeletons(self):
+        return self._root_dataset.skeletons
+
+    @skeletons.setter
+    def skeletons(self, skeletons):
+        self._root_dataset.skeletons = skeletons
+
+    @property
+    def default_skeleton(self):
+        return self._root_dataset.default_skeleton
+
+    @default_skeleton.setter
+    def default_skeleton(self, skeleton):
+        self._root_dataset.default_skeleton = skeleton
+
     def summary(self):
         """Returns a string summary of the view.
 
diff --git a/fiftyone/migrations/revisions/v0_15_1.py b/fiftyone/migrations/revisions/v0_15_1.py
new file mode 100644
index 00000000000..ca71e9a490b
--- /dev/null
+++ b/fiftyone/migrations/revisions/v0_15_1.py
@@ -0,0 +1,30 @@
+"""
+FiftyOne v0.15.1 revision.
+
+| Copyright 2017-2022, Voxel51, Inc.
+| `voxel51.com <https://voxel51.com/>`_
+|
+"""
+
+
+def up(db, dataset_name):
+    match_d = {"name": dataset_name}
+    dataset_dict = db.datasets.find_one(match_d)
+
+    if "skeletons" not in dataset_dict:
+        dataset_dict["skeletons"] = {}
+
+    if "default_skeleton" not in dataset_dict:
+        dataset_dict["default_skeleton"] = None
+
+    db.datasets.replace_one(match_d, dataset_dict)
+
+
+def down(db, dataset_name):
+    match_d = {"name": dataset_name}
+    dataset_dict = db.datasets.find_one(match_d)
+
+    dataset_dict.pop("skeletons", None)
+    dataset_dict.pop("default_skeleton", None)
+
+    db.datasets.replace_one(match_d, dataset_dict)
diff --git a/fiftyone/server/main.py b/fiftyone/server/main.py
index 33afcbd2e77..b36bcc49b29 100644
--- a/fiftyone/server/main.py
+++ b/fiftyone/server/main.py
@@ -61,7 +61,7 @@
 
 
 class RequestHandler(tornado.web.RequestHandler):
-    """"Base class for HTTP request handlers"""
+    """ "Base class for HTTP request handlers"""
 
     def set_default_headers(self, *args, **kwargs):
         self.set_header("Access-Control-Allow-Origin", "*")
@@ -717,7 +717,10 @@ async def on_set_dataset(self, dataset_name):
 
     @staticmethod
     async def on_tag(
-        caller, changes, target_labels=False, active_labels=None,
+        caller,
+        changes,
+        target_labels=False,
+        active_labels=None,
     ):
         state = fos.StateDescription.from_dict(StateHandler.state)
         if state.view is not None:
@@ -752,7 +755,10 @@ async def on_all_tags(caller, sample_id=None):
             label = []
             sample = []
         else:
-            (_, tag_aggs,) = fos.DatasetStatistics.get_label_aggregations(view)
+            (
+                _,
+                tag_aggs,
+            ) = fos.DatasetStatistics.get_label_aggregations(view)
             results = await view._async_aggregate(
                 [foa.Distinct("tags")] + tag_aggs,
             )
@@ -985,7 +991,10 @@ def get_statistics_awaitables(cls, only=None):
 
         return [
             cls.send_statistics(
-                view, extended=False, filters=state.filters, only=only,
+                view,
+                extended=False,
+                filters=state.filters,
+                only=only,
             ),
             cls.send_statistics(
                 view, extended=True, filters=state.filters, only=only
@@ -1196,7 +1205,10 @@ def _label_filter(field):
     ):
         path = field.name
         if issubclass(field.document_type, fol._HasLabelList):
-            path = "%s.%s" % (path, field.document_type._LABEL_LIST_FIELD,)
+            path = "%s.%s" % (
+                path,
+                field.document_type._LABEL_LIST_FIELD,
+            )
 
     return path
 
@@ -1497,7 +1509,11 @@ def __init__(self, **settings):
             (r"/colorscales", ColorscalesHandler),
             (r"/fiftyone", FiftyOneHandler),
             (r"/frames", FramesHandler),
-            (r"/filepath/(.*)", MediaHandler, {"path": ""},),
+            (
+                r"/filepath/(.*)",
+                MediaHandler,
+                {"path": ""},
+            ),
             (r"/notebook", NotebookHandler),
             (r"/page", PageHandler),
             (r"/polling", PollingHandler),
diff --git a/fiftyone/utils/annotations.py b/fiftyone/utils/annotations.py
index 3a3ba9fd4c8..50f4a97c98d 100644
--- a/fiftyone/utils/annotations.py
+++ b/fiftyone/utils/annotations.py
@@ -18,7 +18,6 @@
 import eta.core.utils as etau
 
 import fiftyone as fo
-import fiftyone.core.aggregations as foag
 import fiftyone.core.annotation as foa
 import fiftyone.core.clips as foc
 from fiftyone.core.expressions import ViewField as F
@@ -2166,7 +2165,9 @@ def __init__(self, d):
         super().__init__(d)
 
 
-def draw_labeled_images(samples, output_dir, label_fields=None, config=None):
+def draw_labeled_images(
+    samples, output_dir, label_fields=None, config=None, **kwargs
+):
     """Renders annotated versions of the images in the collection with the
     specified label data overlaid to the given directory.
 
@@ -2179,16 +2180,19 @@ def draw_labeled_images(samples, output_dir, label_fields=None, config=None):
     Args:
         samples: a :class:`fiftyone.core.collections.SampleCollection`
         output_dir: the directory to write the annotated images
-        label_fields (None): a list of label fields to render. If omitted, all
-            compatiable fields are rendered
+        label_fields (None): a label field or list of label fields to render.
+            If omitted, all compatiable fields are rendered
         config (None): an optional :class:`DrawConfig` configuring how to draw
             the labels
+        **kwargs: optional keyword arguments specifying parameters of the
+            default :class:`DrawConfig` to override
 
     Returns:
         the list of paths to the labeled images
     """
-    if config is None:
-        config = DrawConfig.default()
+    config = _parse_draw_config(
+        config, kwargs, samples=samples, label_fields=label_fields
+    )
 
     filename_maker = fou.UniqueFilenameMaker(output_dir=output_dir)
     output_ext = fo.config.default_image_ext
@@ -2206,20 +2210,23 @@ def draw_labeled_images(samples, output_dir, label_fields=None, config=None):
     return outpaths
 
 
-def draw_labeled_image(sample, outpath, label_fields=None, config=None):
+def draw_labeled_image(
+    sample, outpath, label_fields=None, config=None, **kwargs
+):
     """Renders an annotated version of the sample's image with the specified
     label data overlaid to disk.
 
     Args:
         sample: a :class:`fiftyone.core.sample.Sample`
         outpath: the path to write the annotated image
-        label_fields (None): a list of label fields to render. If omitted, all
-            compatiable fields are rendered
+        label_fields (None): a label field or list of label fields to render.
+            If omitted, all compatiable fields are rendered
         config (None): an optional :class:`DrawConfig` configuring how to draw
             the labels
+        **kwargs: optional keyword arguments specifying parameters of the
+            default :class:`DrawConfig` to override
     """
-    if config is None:
-        config = DrawConfig.default()
+    config = _parse_draw_config(config, kwargs)
 
     fov.validate_image_sample(sample)
     img = etai.read(sample.filepath)
@@ -2230,7 +2237,9 @@ def draw_labeled_image(sample, outpath, label_fields=None, config=None):
     etai.write(anno_img, outpath)
 
 
-def draw_labeled_videos(samples, output_dir, label_fields=None, config=None):
+def draw_labeled_videos(
+    samples, output_dir, label_fields=None, config=None, **kwargs
+):
     """Renders annotated versions of the videos in the collection with the
     specified label data overlaid to the given directory.
 
@@ -2243,16 +2252,19 @@ def draw_labeled_videos(samples, output_dir, label_fields=None, config=None):
     Args:
         samples: a :class:`fiftyone.core.collections.SampleCollection`
         output_dir: the directory to write the annotated videos
-        label_fields (None): a list of label fields to render. If omitted, all
-            compatiable fields are rendered
+        label_fields (None): a label field or list of label fields to render.
+            If omitted, all compatiable fields are rendered
         config (None): an optional :class:`DrawConfig` configuring how to draw
             the labels
+        **kwargs: optional keyword arguments specifying parameters of the
+            default :class:`DrawConfig` to override
 
     Returns:
         the list of paths to the labeled videos
     """
-    if config is None:
-        config = DrawConfig.default()
+    config = _parse_draw_config(
+        config, kwargs, samples=samples, label_fields=label_fields
+    )
 
     filename_maker = fou.UniqueFilenameMaker(output_dir=output_dir)
     output_ext = fo.config.default_video_ext
@@ -2281,7 +2293,7 @@ def draw_labeled_videos(samples, output_dir, label_fields=None, config=None):
 
 
 def draw_labeled_video(
-    sample, outpath, support=None, label_fields=None, config=None
+    sample, outpath, support=None, label_fields=None, config=None, **kwargs
 ):
     """Renders an annotated version of the sample's video with the specified
     label data overlaid to disk.
@@ -2291,13 +2303,14 @@ def draw_labeled_video(
         outpath: the path to write the annotated image
         support (None): an optional ``[first, last]`` range of frames to
             render
-        label_fields (None): a list of label fields to render. If omitted, all
-            compatiable fields are rendered
+        label_fields (None): a label field or list of label fields to render.
+            If omitted, all compatiable fields are rendered
         config (None): an optional :class:`DrawConfig` configuring how to draw
             the labels
+        **kwargs: optional keyword arguments specifying parameters of the
+            default :class:`DrawConfig` to override
     """
-    if config is None:
-        config = DrawConfig.default()
+    config = _parse_draw_config(config, kwargs)
 
     video_path = sample.filepath
     video_labels = _to_video_labels(sample, label_fields=label_fields)
@@ -2314,7 +2327,51 @@ def draw_labeled_video(
     )
 
 
+def _parse_draw_config(config, kwargs, samples=None, label_fields=None):
+    if kwargs:
+        if config is not None:
+            d = config.serialize()
+            d.update(kwargs)
+        else:
+            d = kwargs
+
+        config = DrawConfig(d)
+
+    if config is None:
+        config = DrawConfig.default()
+
+    if samples is not None:
+        skeleton = _get_skeleton(samples, label_fields=label_fields)
+        if skeleton is not None:
+            config.keypoints_skeleton = dict(skeleton.to_dict())
+
+    return config
+
+
+def _get_skeleton(samples, label_fields=None):
+    if label_fields is None:
+        if samples.default_skeleton is not None:
+            return samples.default_skeleton
+
+        if samples.skeletons:
+            return next(iter(samples.skeletons.values()))
+
+        return None
+
+    if not etau.is_container(label_fields):
+        label_fields = [label_fields]
+
+    for label_field in label_fields:
+        if label_field in samples.skeletons:
+            return samples.skeletons[label_field]
+
+    return samples.default_skeleton
+
+
 def _to_image_labels(sample, label_fields=None):
+    if label_fields is not None and not etau.is_container(label_fields):
+        label_fields = [label_fields]
+
     labels = _get_sample_labels(sample, label_fields)
     return foue.to_image_labels(labels)
 
diff --git a/fiftyone/utils/coco.py b/fiftyone/utils/coco.py
index 168cd25dc0b..60bd3bcca62 100644
--- a/fiftyone/utils/coco.py
+++ b/fiftyone/utils/coco.py
@@ -976,16 +976,11 @@ def to_keypoints(self, frame_size, classes=None):
         points = []
         for x, y, v in fou.iter_batches(self.keypoints, 3):
             if v == 0:
-                continue
-
-            points.append((x / width, y / height))
+                points.append((float("nan"), float("nan")))
+            else:
+                points.append((x / width, y / height))
 
-        return fol.Keypoint(
-            label=label,
-            points=points,
-            confidence=self.score,
-            **self.attributes,
-        )
+        return fol.Keypoint(label=label, points=points, **self.attributes)
 
     def to_detection(
         self,
diff --git a/fiftyone/utils/data/exporters.py b/fiftyone/utils/data/exporters.py
index 143689e9784..94957ddeeb5 100644
--- a/fiftyone/utils/data/exporters.py
+++ b/fiftyone/utils/data/exporters.py
@@ -1557,6 +1557,14 @@ def log_collection(self, sample_collection):
                 "default_mask_targets"
             ] = sample_collection._serialize_default_mask_targets()
 
+        if sample_collection.skeletons:
+            info["skeletons"] = sample_collection._serialize_skeletons()
+
+        if sample_collection.default_skeleton:
+            info[
+                "default_skeleton"
+            ] = sample_collection._serialize_default_skeleton()
+
         self._metadata["info"] = info
 
         # Exporting runs only makes sense if the entire dataset is being
diff --git a/fiftyone/utils/data/importers.py b/fiftyone/utils/data/importers.py
index ab0363e6182..4af4d3a71d8 100644
--- a/fiftyone/utils/data/importers.py
+++ b/fiftyone/utils/data/importers.py
@@ -588,6 +588,21 @@ def parse_dataset_info(dataset, info, overwrite=True):
                 default_mask_targets
             )
 
+    skeletons = info.pop("skeletons", None)
+    if skeletons is not None:
+        skeletons = dataset._parse_skeletons(skeletons)
+        if overwrite:
+            dataset.skeletons.update(skeletons)
+        else:
+            _update_no_overwrite(dataset.skeletons, skeletons)
+
+    default_skeleton = info.pop("default_skeleton", None)
+    if default_skeleton is not None:
+        if overwrite or not dataset.default_skeleton:
+            dataset.default_skeleton = dataset._parse_default_skeleton(
+                default_skeleton
+            )
+
     if overwrite:
         dataset.info.update(info)
     else:
diff --git a/fiftyone/utils/eta.py b/fiftyone/utils/eta.py
index d627cbb1d0e..0999e9da1e9 100644
--- a/fiftyone/utils/eta.py
+++ b/fiftyone/utils/eta.py
@@ -737,9 +737,9 @@ def to_keypoints(keypoint, name=None, extra_attrs=True):
     return etak.Keypoints(
         name=name,
         label=keypoint.label,
-        confidence=keypoint.confidence,
         index=keypoint.index,
         points=keypoint.points,
+        confidence=keypoint.confidence,
         attrs=attrs,
         tags=keypoint.tags,
     )
diff --git a/fiftyone/utils/labels.py b/fiftyone/utils/labels.py
index d1c709b99ba..9950906583e 100644
--- a/fiftyone/utils/labels.py
+++ b/fiftyone/utils/labels.py
@@ -5,6 +5,8 @@
 | `voxel51.com <https://voxel51.com/>`_
 |
 """
+from fiftyone import ViewField as F
+import fiftyone.core.expressions as foe
 import fiftyone.core.labels as fol
 import fiftyone.core.validation as fov
 
@@ -341,3 +343,115 @@ def classification_to_detections(sample_collection, in_field, out_field):
             image[out_field] = fol.Detections(detections=[detection])
 
         sample.save()
+
+
+def filter_keypoints(sample_collection, field, expr=None, labels=None):
+    """Returns a view that filters the individual
+    :attr:`points <fiftyone.core.labels.Keypoint.points>` in the specified
+    keypoints field.
+
+    Use :meth:`filter_labels <fiftyone.core.collections.SampleCollection.filter_labels`
+    if you simply want to filter entire :class:`fiftyone.core.labels.Keypoint`
+    objects in a field.
+
+    Args:
+        sample_collection: a
+            :class:`fiftyone.core.collections.SampleCollection`
+        field: the name of the :class:`fiftyone.core.labels.Keypoint` or
+            :class:`fiftyone.core.labels.Keypoints` field
+        expr (None): a boolean
+            :class:`fiftyone.core.expressions.ViewExpression` like
+            ``F("confidence") > 0.5`` or ``F("occluded") == False`` to apply
+            elementwise to the specified field, which must be a list of same
+            length as :attr:`fiftyone.core.labels.Keypoint.points`
+        labels (None): an optional iterable of specific keypoint labels to keep
+
+    Returns:
+        a :class:`fiftyone.core.view.DatasetView`
+    """
+    _, path = sample_collection._get_label_field_path(field, "points")
+
+    view = sample_collection.view()
+
+    if expr is not None:
+        field, expr = _extract_field(expr)
+
+        view = view.set_field(
+            path,
+            (F(field) != None).if_else(
+                F.zip(F("points"), F(field)).map(
+                    (F()[1].apply(expr)).if_else(
+                        F()[0], [float("nan"), float("nan")],
+                    )
+                ),
+                F("points"),
+            ),
+        )
+
+    if labels is not None:
+        skeleton = sample_collection.get_skeleton(field)
+        if skeleton is None:
+            raise ValueError(
+                "No keypoint skeleton found for field '%s'" % field
+            )
+
+        if skeleton.labels is None:
+            raise ValueError(
+                "Keypoint skeleton for field '%s' has no labels" % field
+            )
+
+        labels = set(labels)
+        inds = [
+            idx for idx, label in enumerate(skeleton.labels) if label in labels
+        ]
+        view = view.set_field(
+            path,
+            F.enumerate(F("points")).map(
+                F()[0]
+                .is_in(inds)
+                .if_else(F()[1], [float("nan"), float("nan")])
+            ),
+        )
+
+    return view
+
+
+def _extract_field(val):
+    field = None
+
+    if isinstance(val, foe.ViewField) and not val.is_frozen:
+        field = val._expr
+        val._expr = ""
+        return field, val
+
+    if isinstance(val, foe.ViewExpression):
+        field, val._expr = _extract_field(val._expr)
+        return field, val
+
+    if isinstance(val, dict):
+        _val = {}
+        for k, v in val.items():
+            _field, _k = _extract_field(k)
+            if _field is not None:
+                field = _field
+
+            _field, _v = _extract_field(v)
+            if _field is not None:
+                field = _field
+
+            _val[_k] = _v
+
+        return field, _val
+
+    if isinstance(val, list):
+        _val = []
+        for v in val:
+            _field, _v = _extract_field(v)
+            if _field is not None:
+                field = _field
+
+            _val.append(_v)
+
+        return field, _val
+
+    return field, val
diff --git a/fiftyone/utils/torch.py b/fiftyone/utils/torch.py
index dc0c6c6da30..3599ee5c93e 100644
--- a/fiftyone/utils/torch.py
+++ b/fiftyone/utils/torch.py
@@ -22,6 +22,7 @@
 import fiftyone.core.config as foc
 import fiftyone.core.labels as fol
 import fiftyone.core.models as fom
+import fiftyone.core.odm as foo
 import fiftyone.core.utils as fou
 
 fou.ensure_torch()
@@ -102,12 +103,17 @@ class TorchImageModelConfig(foc.Config):
             ``"fifytone.utils.torch.ClassifierOutputProcessor"`` specifying the
             :class:`fifytone.utils.torch.OutputProcessor` to use
         output_processor_args (None): a dictionary of arguments for
-            ``output_processor_cls(class_labels, **kwargs)``
+            ``output_processor_cls(classes=classes, **kwargs)``
         confidence_thresh (None): an optional confidence threshold apply to any
             applicable predictions generated by the model
         labels_string (None): a comma-separated list of the class names for the
-            model
-        labels_path (None): the path to the labels map for the model
+            model, if applicable
+        labels_path (None): the path to the labels map for the model, if
+            applicable
+        mask_targets (None): a mask targets dict for the model, if applicable
+        mask_targets_path (None): the path to a mask targets map for the model,
+            if applicable
+        skeleton (None): a keypoint skeleton dict for the model, if applicable
         image_min_size (None): resize the input images during preprocessing, if
             necessary, so that the image dimensions are at least this
             ``(width, height)``
@@ -156,6 +162,11 @@ def __init__(self, d):
             d, "labels_string", default=None
         )
         self.labels_path = self.parse_string(d, "labels_path", default=None)
+        self.mask_targets = self.parse_dict(d, "mask_targets", default=None)
+        self.mask_targets_path = self.parse_string(
+            d, "mask_targets_path", default=None
+        )
+        self.skeleton = self.parse_dict(d, "skeleton", default=None)
         self.image_min_size = self.parse_array(
             d, "image_min_size", default=None
         )
@@ -199,9 +210,10 @@ class TorchImageModel(
     def __init__(self, config):
         self.config = config
 
-        # Get class labels
-        self._class_labels = self._get_class_labels(config)
-        self._num_classes = len(self._class_labels)
+        # Parse model details
+        self._classes = self._parse_classes(config)
+        self._mask_targets = self._parse_mask_targets(config)
+        self._skeleton = self._parse_skeleton(config)
 
         # Build transforms
         ragged_batches, transforms = self._build_transforms(config)
@@ -294,15 +306,28 @@ def using_half_precision(self):
         """Whether the model is using half precision."""
         return self._using_half_precision
 
+    @property
+    def classes(self):
+        """The list of class labels for the model, if known."""
+        return self._classes
+
     @property
     def num_classes(self):
-        """The number of classes for the model."""
-        return self._num_classes
+        """The number of classes for the model, if known."""
+        if self._classes is not None:
+            return len(self._classes)
+
+        return None
 
     @property
-    def class_labels(self):
-        """The list of class labels for the model."""
-        return self._class_labels
+    def mask_targets(self):
+        """The mask targets for the model, if any."""
+        return self._mask_targets
+
+    @property
+    def skeleton(self):
+        """The keypoint skeleton for the model, if any."""
+        return self._skeleton
 
     def predict(self, img):
         """Peforms prediction on the given image.
@@ -371,18 +396,36 @@ def _predict_all(self, imgs):
             output, frame_size, confidence_thresh=self.config.confidence_thresh
         )
 
-    def _get_class_labels(self, config):
-        if config.labels_string:
+    def _parse_classes(self, config):
+        if config.labels_string is not None:
             return config.labels_string.split(",")
 
-        if not config.labels_path:
-            raise ValueError(
-                "Either `labels_string` or `labels_path` must be specified"
-            )
+        if config.labels_path is not None:
+            labels_path = fou.fill_patterns(config.labels_path)
+            labels_map = etal.load_labels_map(labels_path)
+            return etal.get_class_labels(labels_map)
+
+        mask_targets = self._parse_mask_targets(config)
+        if mask_targets is not None:
+            return sorted(v for k, v in mask_targets.items() if v != 0)
 
-        labels_path = fou.fill_patterns(config.labels_path)
-        labels_map = etal.load_labels_map(labels_path)
-        return etal.get_class_labels(labels_map)
+        return None
+
+    def _parse_mask_targets(self, config):
+        if config.mask_targets is not None:
+            return config.mask_targets
+
+        if config.mask_targets_path is not None:
+            labels_path = fou.fill_patterns(config.mask_targets_path)
+            return etal.load_labels_map(labels_path)
+
+        return None
+
+    def _parse_skeleton(self, config):
+        if config.skeleton is not None:
+            return foo.KeypointSkeleton.from_dict(config.skeleton)
+
+        return None
 
     def _build_transforms(self, config):
         ragged_batches = True
@@ -451,7 +494,7 @@ def _load_state_dict(self, model, config):
     def _build_output_processor(self, config):
         output_processor_cls = etau.get_class(config.output_processor_cls)
         kwargs = config.output_processor_args or {}
-        return output_processor_cls(self._class_labels, **kwargs)
+        return output_processor_cls(classes=self._classes, **kwargs)
 
 
 class ToPILImage(object):
@@ -584,7 +627,12 @@ def _setup(self, model, layer_name):
 
 
 class OutputProcessor(object):
-    """Interface for processing the outputs of Torch models."""
+    """Interface for processing the outputs of Torch models.
+
+    Args:
+        classes (None): the list of class labels for the model. This may not be
+            required or used by some models
+    """
 
     def __call__(self, output, frame_size, confidence_thresh=None):
         """Parses the model output.
@@ -605,12 +653,17 @@ class ClassifierOutputProcessor(OutputProcessor):
     """Output processor for single label classifiers.
 
     Args:
-        class_labels: the list of class labels for the model
+        classes (None): the list of class labels for the model
         store_logits (False): whether to store logits in the model outputs
     """
 
-    def __init__(self, class_labels, store_logits=False):
-        self.class_labels = class_labels
+    def __init__(self, classes=None, store_logits=False):
+        if classes is None:
+            raise ValueError(
+                "This model requires class labels, but none were available"
+            )
+
+        self.classes = classes
         self.store_logits = store_logits
 
     def __call__(self, output, _, confidence_thresh=None):
@@ -646,7 +699,7 @@ def __call__(self, output, _, confidence_thresh=None):
                 classification = None
             else:
                 classification = fol.Classification(
-                    label=self.class_labels[prediction], confidence=score,
+                    label=self.classes[prediction], confidence=score,
                 )
                 if self.store_logits:
                     classification.logits = _logits
@@ -660,11 +713,16 @@ class DetectorOutputProcessor(OutputProcessor):
     """Output processor for object detectors.
 
     Args:
-        class_labels: the list of class labels for the model
+        classes (None): the list of class labels for the model
     """
 
-    def __init__(self, class_labels):
-        self.class_labels = class_labels
+    def __init__(self, classes=None):
+        if classes is None:
+            raise ValueError(
+                "This model requires class labels, but none were available"
+            )
+
+        self.classes = classes
 
     def __call__(self, output, frame_size, confidence_thresh=None):
         """Parses the model output.
@@ -712,7 +770,7 @@ def _parse_output(self, output, frame_size, confidence_thresh):
 
             detections.append(
                 fol.Detection(
-                    label=self.class_labels[label],
+                    label=self.classes[label],
                     bounding_box=bounding_box,
                     confidence=score,
                 )
@@ -725,13 +783,18 @@ class InstanceSegmenterOutputProcessor(OutputProcessor):
     """Output processor for instance segementers.
 
     Args:
-        class_labels: the list of class labels for the model
+        classes (None): the list of class labels for the model
         mask_thresh (0.5): a threshold to use to convert soft masks to binary
             masks
     """
 
-    def __init__(self, class_labels, mask_thresh=0.5):
-        self.class_labels = class_labels
+    def __init__(self, classes=None, mask_thresh=0.5):
+        if classes is None:
+            raise ValueError(
+                "This model requires class labels, but none were available"
+            )
+
+        self.classes = classes
         self.mask_thresh = mask_thresh
 
     def __call__(self, output, frame_size, confidence_thresh=None):
@@ -789,7 +852,7 @@ def _parse_output(self, output, frame_size, confidence_thresh):
 
             detections.append(
                 fol.Detection(
-                    label=self.class_labels[label],
+                    label=self.classes[label],
                     bounding_box=bounding_box,
                     mask=mask,
                     confidence=score,
@@ -803,14 +866,16 @@ class KeypointDetectorOutputProcessor(OutputProcessor):
     """Output processor for keypoint detection models.
 
     Args:
-        class_labels: the list of class labels for the model
-        edges (None): an optional list of list of vertices specifying polyline
-            connections between keypoints to draw
+        classes (None): the list of class labels for the model
     """
 
-    def __init__(self, class_labels, edges=None):
-        self.class_labels = class_labels
-        self.edges = edges
+    def __init__(self, classes=None):
+        if classes is None:
+            raise ValueError(
+                "This model requires class labels, but none were available"
+            )
+
+        self.classes = classes
 
     def __call__(self, output, frame_size, confidence_thresh=None):
         """Parses the model output.
@@ -824,7 +889,7 @@ def __call__(self, output, frame_size, confidence_thresh=None):
                 -   labels (``Int64Tensor[N]``): the predicted labels
                 -   scores (``Tensor[N]``): the scores for each prediction
                 -   keypoints (``FloatTensor[N, K, ...]``): the predicted
-                    keypoints for each instance in ``[x, y, ...]`` format
+                    keypoints for each instance in ``[x, y, v, ...]`` format
 
             frame_size: the ``(width, height)`` of the frames in the batch
             confidence_thresh (None): an optional confidence threshold to use
@@ -845,11 +910,15 @@ def _parse_output(self, output, frame_size, confidence_thresh):
         labels = output["labels"].detach().cpu().numpy()
         scores = output["scores"].detach().cpu().numpy()
         keypoints = output["keypoints"].detach().cpu().numpy()
+        keypoints_scores = torch.sigmoid(
+            output["keypoints_scores"].detach().cpu()
+        ).numpy()
 
         _detections = []
         _keypoints = []
-        _polylines = []
-        for box, label, score, kpts in zip(boxes, labels, scores, keypoints):
+        for box, label, score, kpts, kpt_scores in zip(
+            boxes, labels, scores, keypoints, keypoints_scores
+        ):
             if confidence_thresh is not None and score < confidence_thresh:
                 continue
 
@@ -861,11 +930,16 @@ def _parse_output(self, output, frame_size, confidence_thresh):
                 (y2 - y1) / height,
             ]
 
-            points = [(p[0] / width, p[1] / height) for p in kpts]
+            points = []
+            for p in kpts:
+                if p[2] > 0:
+                    points.append((p[0] / width, p[1] / height))
+                else:
+                    points.append((float("nan"), float("nan")))
 
             _detections.append(
                 fol.Detection(
-                    label=self.class_labels[label],
+                    label=self.classes[label],
                     bounding_box=bounding_box,
                     confidence=score,
                 )
@@ -873,42 +947,28 @@ def _parse_output(self, output, frame_size, confidence_thresh):
 
             _keypoints.append(
                 fol.Keypoint(
-                    label=self.class_labels[label],
+                    label=self.classes[label],
                     points=points,
-                    confidence=score,
+                    confidence=kpt_scores.tolist(),
                 )
             )
 
-            if self.edges is not None:
-                _polylines.append(
-                    fol.Polyline(
-                        points=[[points[v] for v in e] for e in self.edges],
-                        confidence=score,
-                        closed=False,
-                        filled=False,
-                    )
-                )
-
-        label = {
+        return {
             "detections": fol.Detections(detections=_detections),
             "keypoints": fol.Keypoints(keypoints=_keypoints),
         }
 
-        if self.edges is not None:
-            label["polylines"] = fol.Polylines(polylines=_polylines)
-
-        return label
-
 
-class SegmenterOutputProcessor(OutputProcessor):
+class SemanticSegmenterOutputProcessor(OutputProcessor):
     """Output processor for semantic segementers.
 
     Args:
-        class_labels: the list of class labels for the model
+        classes (None): the list of class labels for the model. This parameter
+            is not used
     """
 
-    def __init__(self, class_labels):
-        self.class_labels = class_labels
+    def __init__(self, classes=None):
+        self.classes = classes
 
     def __call__(self, output, *args, **kwargs):
         """Parses the model output.
diff --git a/fiftyone/zoo/datasets/tf.py b/fiftyone/zoo/datasets/tf.py
index 42d0134e2f2..504c57c9970 100644
--- a/fiftyone/zoo/datasets/tf.py
+++ b/fiftyone/zoo/datasets/tf.py
@@ -81,14 +81,14 @@ def download_fcn(download_dir):
                 with_info=True,
             )
 
-        get_class_labels_fcn = lambda info: info.features["label"].names
+        get_classes_fcn = lambda info: info.features["label"].names
         get_num_samples_fcn = lambda info: info.splits[split].num_examples
         sample_parser = _TFDSImageClassificationSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             get_num_samples_fcn,
             sample_parser,
         )
@@ -138,14 +138,14 @@ def download_fcn(download_dir):
                 with_info=True,
             )
 
-        get_class_labels_fcn = lambda info: info.features["label"].names
+        get_classes_fcn = lambda info: info.features["label"].names
         get_num_samples_fcn = lambda info: info.splits[split].num_examples
         sample_parser = _TFDSImageClassificationSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             get_num_samples_fcn,
             sample_parser,
         )
@@ -196,14 +196,14 @@ def download_fcn(download_dir):
                 with_info=True,
             )
 
-        get_class_labels_fcn = lambda info: info.features["label"].names
+        get_classes_fcn = lambda info: info.features["label"].names
         get_num_samples_fcn = lambda info: info.splits[split].num_examples
         sample_parser = _TFDSImageClassificationSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             get_num_samples_fcn,
             sample_parser,
         )
@@ -254,14 +254,14 @@ def download_fcn(download_dir):
                 with_info=True,
             )
 
-        get_class_labels_fcn = lambda info: info.features["label"].names
+        get_classes_fcn = lambda info: info.features["label"].names
         get_num_samples_fcn = lambda info: info.splits[split].num_examples
         sample_parser = _TFDSImageClassificationSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             get_num_samples_fcn,
             sample_parser,
         )
@@ -358,14 +358,14 @@ def download_fcn(_):
                 download_and_prepare_kwargs={"download_dir": scratch_dir},
             )
 
-        get_class_labels_fcn = lambda info: info.features["label"].names
+        get_classes_fcn = lambda info: info.features["label"].names
         get_num_samples_fcn = lambda info: info.splits[_split].num_examples
         sample_parser = _TFDSImageClassificationSampleParser()
         return _download_and_prepare(
             dataset_dir,
             None,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             get_num_samples_fcn,
             sample_parser,
         )
@@ -420,9 +420,7 @@ def download_fcn(download_dir):
                 with_info=True,
             )
 
-        get_class_labels_fcn = lambda info: info.features["objects"][
-            "label"
-        ].names
+        get_classes_fcn = lambda info: info.features["objects"]["label"].names
         get_num_samples_fcn = lambda info: info.splits[split].num_examples
         sample_parser = _TFDSImageDetectionSampleParser(
             bounding_box_field="bbox"
@@ -431,7 +429,7 @@ def download_fcn(download_dir):
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             get_num_samples_fcn,
             sample_parser,
         )
@@ -486,9 +484,7 @@ def download_fcn(download_dir):
                 with_info=True,
             )
 
-        get_class_labels_fcn = lambda info: info.features["objects"][
-            "label"
-        ].names
+        get_classes_fcn = lambda info: info.features["objects"]["label"].names
         get_num_samples_fcn = lambda info: info.splits[split].num_examples
         sample_parser = _TFDSImageDetectionSampleParser(
             bounding_box_field="bbox"
@@ -497,7 +493,7 @@ def download_fcn(download_dir):
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             get_num_samples_fcn,
             sample_parser,
         )
@@ -587,7 +583,7 @@ def _download_and_prepare(
     dataset_dir,
     scratch_dir,
     download_fcn,
-    get_class_labels_fcn,
+    get_classes_fcn,
     get_num_samples_fcn,
     sample_parser,
 ):
@@ -600,7 +596,7 @@ def _download_and_prepare(
     with fou.ResourceLimit("RLIMIT_NOFILE", soft=4096, warn_on_failure=True):
         dataset, info = download_fcn(scratch_dir)
 
-    classes = get_class_labels_fcn(info)
+    classes = get_classes_fcn(info)
     num_samples = get_num_samples_fcn(info)
     sample_parser.classes = classes
     label_cls = sample_parser.label_cls
diff --git a/fiftyone/zoo/datasets/torch.py b/fiftyone/zoo/datasets/torch.py
index 231b0141b4e..5470051dac3 100644
--- a/fiftyone/zoo/datasets/torch.py
+++ b/fiftyone/zoo/datasets/torch.py
@@ -83,13 +83,13 @@ def download_fcn(download_dir):
                 download_dir, train=train, download=True
             )
 
-        get_class_labels_fcn = _parse_classification_labels
+        get_classes_fcn = _parse_classification_labels
         sample_parser = foud.ImageClassificationSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             sample_parser,
         )
 
@@ -136,13 +136,13 @@ def download_fcn(download_dir):
                 download_dir, train=train, download=True
             )
 
-        get_class_labels_fcn = _parse_classification_labels
+        get_classes_fcn = _parse_classification_labels
         sample_parser = foud.ImageClassificationSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             sample_parser,
         )
 
@@ -188,13 +188,13 @@ def download_fcn(download_dir):
                 download_dir, train=train, download=True
             )
 
-        get_class_labels_fcn = _parse_classification_labels
+        get_classes_fcn = _parse_classification_labels
         sample_parser = foud.ImageClassificationSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             sample_parser,
         )
 
@@ -242,13 +242,13 @@ def download_fcn(download_dir):
                 download_dir, train=train, download=True
             )
 
-        get_class_labels_fcn = _parse_classification_labels
+        get_classes_fcn = _parse_classification_labels
         sample_parser = foud.ImageClassificationSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             sample_parser,
         )
 
@@ -340,14 +340,10 @@ def _download_and_prepare(self, dataset_dir, _, split):
         def download_fcn(_):
             return torchvision.datasets.ImageNet(self.source_dir, split=_split)
 
-        get_class_labels_fcn = _parse_classification_labels
+        get_classes_fcn = _parse_classification_labels
         sample_parser = foud.ImageClassificationSampleParser()
         return _download_and_prepare(
-            dataset_dir,
-            None,
-            download_fcn,
-            get_class_labels_fcn,
-            sample_parser,
+            dataset_dir, None, download_fcn, get_classes_fcn, sample_parser,
         )
 
 
@@ -401,13 +397,13 @@ def download_fcn(download_dir):
                 download_dir, year="2007", image_set=image_set, download=True,
             )
 
-        get_class_labels_fcn = _parse_voc_detection_labels
+        get_classes_fcn = _parse_voc_detection_labels
         sample_parser = _VOCDetectionSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             sample_parser,
         )
 
@@ -462,13 +458,13 @@ def download_fcn(download_dir):
                 download_dir, year="2012", image_set=image_set, download=True,
             )
 
-        get_class_labels_fcn = _parse_voc_detection_labels
+        get_classes_fcn = _parse_voc_detection_labels
         sample_parser = _VOCDetectionSampleParser()
         return _download_and_prepare(
             dataset_dir,
             scratch_dir,
             download_fcn,
-            get_class_labels_fcn,
+            get_classes_fcn,
             sample_parser,
         )
 
@@ -508,16 +504,12 @@ def _parse_label(self, target, img=None):
 
 
 def _download_and_prepare(
-    dataset_dir,
-    scratch_dir,
-    download_fcn,
-    get_class_labels_fcn,
-    sample_parser,
+    dataset_dir, scratch_dir, download_fcn, get_classes_fcn, sample_parser,
 ):
     # Download the torchvision dataset, if necessary
     dataset = download_fcn(scratch_dir)
 
-    classes = get_class_labels_fcn(dataset)
+    classes = get_classes_fcn(dataset)
     num_samples = len(dataset)
     sample_parser.classes = classes
     label_cls = sample_parser.label_cls
diff --git a/fiftyone/zoo/models/manifest-torch.json b/fiftyone/zoo/models/manifest-torch.json
index 4b042a72e4d..0dc8e84cba4 100644
--- a/fiftyone/zoo/models/manifest-torch.json
+++ b/fiftyone/zoo/models/manifest-torch.json
@@ -5,7 +5,7 @@
             "base_filename": "alexnet-owt-4df8aa71.pth",
             "version": null,
             "description": "AlexNet model architecture from `One weird trick for parallelizing convolutional neural networks <https://arxiv.org/abs/1404.5997>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 244418560,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -50,7 +50,7 @@
             "base_filename": "deeplabv3_resnet101_coco-586e9e4e.pth",
             "version": null,
             "description": "DeepLabV3 model from `Rethinking Atrous Convolution for Semantic Image Segmentation <https://arxiv.org/abs/1706.05587>`_ with ResNet-101 backbone trained on COCO",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 244545539,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -63,8 +63,8 @@
                 "config": {
                     "entrypoint_fcn": "torchvision.models.segmentation.segmentation.deeplabv3_resnet101",
                     "entrypoint_args": { "pretrained": true },
-                    "output_processor_cls": "fiftyone.utils.torch.SegmenterOutputProcessor",
-                    "labels_path": "{{eta-resources}}/voc-labels.txt",
+                    "output_processor_cls": "fiftyone.utils.torch.SemanticSegmenterOutputProcessor",
+                    "mask_targets_path": "{{eta-resources}}/voc-labels.txt",
                     "image_dim": 520,
                     "image_mean": [0.485, 0.456, 0.406],
                     "image_std": [0.229, 0.224, 0.225]
@@ -87,7 +87,7 @@
             "base_filename": "deeplabv3_resnet50_coco-cd0a2569.pth",
             "version": null,
             "description": "DeepLabV3 model from `Rethinking Atrous Convolution for Semantic Image Segmentation <https://arxiv.org/abs/1706.05587>`_ with ResNet-50 backbone trained on COCO",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 168312152,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -100,8 +100,8 @@
                 "config": {
                     "entrypoint_fcn": "torchvision.models.segmentation.segmentation.deeplabv3_resnet50",
                     "entrypoint_args": { "pretrained": true },
-                    "output_processor_cls": "fiftyone.utils.torch.SegmenterOutputProcessor",
-                    "labels_path": "{{eta-resources}}/voc-labels.txt",
+                    "output_processor_cls": "fiftyone.utils.torch.SemanticSegmenterOutputProcessor",
+                    "mask_targets_path": "{{eta-resources}}/voc-labels.txt",
                     "image_dim": 520,
                     "image_mean": [0.485, 0.456, 0.406],
                     "image_std": [0.229, 0.224, 0.225]
@@ -124,7 +124,7 @@
             "base_filename": "densenet121-a639ec97.pth",
             "version": null,
             "description": "Densenet-121 model from `Densely Connected Convolutional Networks <https://arxiv.org/pdf/1608.06993.pdf>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 32342954,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -169,7 +169,7 @@
             "base_filename": "densenet161-8d451a50.pth",
             "version": null,
             "description": "Densenet-161 model from `Densely Connected Convolutional Networks <https://arxiv.org/pdf/1608.06993.pdf>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 115730790,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -214,7 +214,7 @@
             "base_filename": "densenet169-b2777c0a.pth",
             "version": null,
             "description": "Densenet-169 model from `Densely Connected Convolutional Networks <https://arxiv.org/pdf/1608.06993.pdf>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 57365526,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -259,7 +259,7 @@
             "base_filename": "densenet201-c1103571.pth",
             "version": null,
             "description": "Densenet-201 model from `Densely Connected Convolutional Networks <https://arxiv.org/pdf/1608.06993.pdf>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 81131730,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -304,7 +304,7 @@
             "base_filename": "fasterrcnn_resnet50_fpn_coco-258fb6c6.pth",
             "version": null,
             "description": "Faster R-CNN model from `Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks <https://arxiv.org/abs/1506.01497>`_ with ResNet-50 FPN backbone trained on COCO",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 167502836,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -339,7 +339,7 @@
             "base_filename": "fcn_resnet101_coco-7ecb50ca.pth",
             "version": null,
             "description": "FCN model from `Fully Convolutional Networks for Semantic Segmentation <https://arxiv.org/abs/1411.4038>`_ with ResNet-101 backbone trained on COCO",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 217800805,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -352,8 +352,8 @@
                 "config": {
                     "entrypoint_fcn": "torchvision.models.segmentation.segmentation.fcn_resnet101",
                     "entrypoint_args": { "pretrained": true },
-                    "output_processor_cls": "fiftyone.utils.torch.SegmenterOutputProcessor",
-                    "labels_path": "{{eta-resources}}/voc-labels.txt",
+                    "output_processor_cls": "fiftyone.utils.torch.SemanticSegmenterOutputProcessor",
+                    "mask_targets_path": "{{eta-resources}}/voc-labels.txt",
                     "image_dim": 520,
                     "image_mean": [0.485, 0.456, 0.406],
                     "image_std": [0.229, 0.224, 0.225]
@@ -376,7 +376,7 @@
             "base_filename": "fcn_resnet50_coco-1167a1af.pth",
             "version": null,
             "description": "FCN model from `Fully Convolutional Networks for Semantic Segmentation <https://arxiv.org/abs/1411.4038>`_ with ResNet-50 backbone trained on COCO",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 141567418,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -389,8 +389,8 @@
                 "config": {
                     "entrypoint_fcn": "torchvision.models.segmentation.segmentation.fcn_resnet50",
                     "entrypoint_args": { "pretrained": true },
-                    "output_processor_cls": "fiftyone.utils.torch.SegmenterOutputProcessor",
-                    "labels_path": "{{eta-resources}}/voc-labels.txt",
+                    "output_processor_cls": "fiftyone.utils.torch.SemanticSegmenterOutputProcessor",
+                    "mask_targets_path": "{{eta-resources}}/voc-labels.txt",
                     "image_dim": 520,
                     "image_mean": [0.485, 0.456, 0.406],
                     "image_std": [0.229, 0.224, 0.225]
@@ -413,7 +413,7 @@
             "base_filename": "googlenet-1378be20.pth",
             "version": null,
             "description": "GoogLeNet (Inception v1) model from `Going Deeper with Convolutions <https://arxiv.org/abs/1409.4842>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 52147035,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -458,7 +458,7 @@
             "base_filename": "inception_v3_google-1a9a5a14.pth",
             "version": null,
             "description": "Inception v3 model from `Rethinking the Inception Architecture for Computer Vision <https://arxiv.org/abs/1512.00567>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 108857766,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -502,7 +502,7 @@
             "base_filename": "keypointrcnn_resnet50_fpn_coco-fc266e95.pth",
             "version": null,
             "description": "Keypoint R-CNN model from `Keypoint Density-based Region Proposal for Fine-Grained Object Detection and Classification using Regions with Convolutional Neural Network Features <https://arxiv.org/abs/1603.00502>`_ with ResNet-50 FPN backbone trained on COCO",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 237034793,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -516,15 +516,34 @@
                     "entrypoint_fcn": "torchvision.models.detection.keypoint_rcnn.keypointrcnn_resnet50_fpn",
                     "entrypoint_args": { "pretrained": true },
                     "output_processor_cls": "fiftyone.utils.torch.KeypointDetectorOutputProcessor",
-                    "output_processor_args": {
+                    "labels_string": "other,person",
+                    "confidence_thresh": 0.3,
+                    "skeleton": {
+                        "labels": [
+                            "nose",
+                            "left eye",
+                            "right eye",
+                            "left ear",
+                            "right ear",
+                            "left shoulder",
+                            "right shoulder",
+                            "left elbow",
+                            "right elbow",
+                            "left wrist",
+                            "right wrist",
+                            "left hip",
+                            "right hip",
+                            "left knee",
+                            "right knee",
+                            "left ankle",
+                            "right ankle"
+                        ],
                         "edges": [
                             [11, 5, 3, 1, 0, 2, 4, 6, 12],
                             [9, 7, 5, 6, 8, 10],
                             [15, 13, 11, 12, 14, 16]
                         ]
-                    },
-                    "labels_string": "other,person",
-                    "confidence_thresh": 0.3
+                    }
                 }
             },
             "requirements": {
@@ -544,7 +563,7 @@
             "base_filename": "maskrcnn_resnet50_fpn_coco-bf2d0c1e.pth",
             "version": null,
             "description": "Mask R-CNN model from `Mask R-CNN <https://arxiv.org/abs/1703.06870>`_ with ResNet-50 FPN backbone trained on COCO",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 178090079,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -578,7 +597,7 @@
             "base_filename": "mnasnet0.5_top1_67.823-3ffadce67e.pth",
             "version": null,
             "description": "MNASNet model from from `MnasNet: Platform-Aware Neural Architecture Search for Mobile <https://arxiv.org/abs/1807.11626>`_ with depth multiplier of 0.5 trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 9008489,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -623,7 +642,7 @@
             "base_filename": "mnasnet1.0_top1_73.512-f206786ef8.pth",
             "version": null,
             "description": "MNASNet model from `MnasNet: Platform-Aware Neural Architecture Search for Mobile <https://arxiv.org/abs/1807.11626>`_ with depth multiplier of 1.0 trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 17736997,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -668,7 +687,7 @@
             "base_filename": "mobilenet_v2-b0353104.pth",
             "version": null,
             "description": "MobileNetV2 model from `MobileNetV2: Inverted Residuals and Linear Bottlenecks <https://arxiv.org/abs/1801.04381>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 14212972,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -713,7 +732,7 @@
             "base_filename": "resnet101-5d3b4d8f.pth",
             "version": null,
             "description": "ResNet-101 model from `Deep Residual Learning for Image Recognition <https://arxiv.org/abs/1512.03385>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 178728960,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -758,7 +777,7 @@
             "base_filename": "resnet152-b121ed2d.pth",
             "version": null,
             "description": "ResNet-152 model from `Deep Residual Learning for Image Recognition <https://arxiv.org/abs/1512.03385>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 241530880,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -803,7 +822,7 @@
             "base_filename": "resnet18-5c106cde.pth",
             "version": null,
             "description": "ResNet-18 model from `Deep Residual Learning for Image Recognition <https://arxiv.org/abs/1512.03385>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 46827520,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -848,7 +867,7 @@
             "base_filename": "resnet34-333f7ec4.pth",
             "version": null,
             "description": "ResNet-34 model from `Deep Residual Learning for Image Recognition <https://arxiv.org/abs/1512.03385>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 87306240,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -893,7 +912,7 @@
             "base_filename": "resnet50-19c8e357.pth",
             "version": null,
             "description": "ResNet-50 model from `Deep Residual Learning for Image Recognition <https://arxiv.org/abs/1512.03385>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 102502400,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -938,7 +957,7 @@
             "base_filename": "resnext101_32x8d-8ba56ff5.pth",
             "version": null,
             "description": "ResNeXt-101 32x8d model from `Aggregated Residual Transformations for Deep Neural Networks <https://arxiv.org/abs/1611.05431>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 356082095,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -983,7 +1002,7 @@
             "base_filename": "resnext50_32x4d-7cdf4587.pth",
             "version": null,
             "description": "ResNeXt-50 32x4d model from `Aggregated Residual Transformations for Deep Neural Networks <https://arxiv.org/abs/1611.05431>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 100441675,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1028,7 +1047,7 @@
             "base_filename": "retinanet_resnet50_fpn_coco-eeacb38b.pth",
             "version": null,
             "description": "RetinaNet model from `Focal Loss for Dense Object Detection <https://arxiv.org/abs/1708.02002>`_ with ResNet-50 FPN backbone trained on COCO",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 136595076,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1063,7 +1082,7 @@
             "base_filename": "shufflenetv2_x0.5-f707e7126e.pth",
             "version": null,
             "description": "ShuffleNetV2 model from `ShuffleNet V2: Practical Guidelines for Efficient CNN Architecture Design <https://arxiv.org/abs/1807.11164>`_ with 0.5x output channels trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 5538128,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1108,7 +1127,7 @@
             "base_filename": "shufflenetv2_x1-5666bf0f80.pth",
             "version": null,
             "description": "ShuffleNetV2 model from `ShuffleNet V2: Practical Guidelines for Efficient CNN Architecture Design <https://arxiv.org/abs/1807.11164>`_ with 1.0x output channels trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 9218294,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1153,7 +1172,7 @@
             "base_filename": "squeezenet1_1-f364aa15.pth",
             "version": null,
             "description": "SqueezeNet 1.1 model from `the official SqueezeNet repo <https://github.com/forresti/SqueezeNet/tree/master/SqueezeNet_v1.1>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 4966400,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1191,7 +1210,7 @@
             "base_filename": "squeezenet1_0-a815701f.pth",
             "version": null,
             "description": "SqueezeNet model from `SqueezeNet: AlexNet-level accuracy with 50x fewer parameters and <0.5MB model size <https://arxiv.org/abs/1602.07360>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 5017600,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1229,7 +1248,7 @@
             "base_filename": "vgg11_bn-6002323d.pth",
             "version": null,
             "description": "VGG-11 model from `Very Deep Convolutional Networks for Large-Scale Image Recognition <https://arxiv.org/abs/1409.1556>`_ with batch normalization trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 531503671,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1274,7 +1293,7 @@
             "base_filename": "vgg11-bbd30ac9.pth",
             "version": null,
             "description": "VGG-11 model from `Very Deep Convolutional Networks for Large-Scale Image Recognition <https://arxiv.org/abs/1409.1556>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 531456000,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1319,7 +1338,7 @@
             "base_filename": "vgg13_bn-abd245e5.pth",
             "version": null,
             "description": "VGG-13 model from `Very Deep Convolutional Networks for Large-Scale Image Recognition <https://arxiv.org/abs/1409.1556>`_ with batch normalization trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 532246301,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1364,7 +1383,7 @@
             "base_filename": "vgg13-c768596a.pth",
             "version": null,
             "description": "VGG-13 model from `Very Deep Convolutional Networks for Large-Scale Image Recognition <https://arxiv.org/abs/1409.1556>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 532194478,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1409,7 +1428,7 @@
             "base_filename": "vgg16_bn-6c64b313.pth",
             "version": null,
             "description": "VGG-16 model from `Very Deep Convolutional Networks for Large-Scale Image Recognition <https://arxiv.org/abs/1409.1556>`_ with batch normalization trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 553507836,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1454,7 +1473,7 @@
             "base_filename": "vgg16-397923af.pth",
             "version": null,
             "description": "VGG-16 model from `Very Deep Convolutional Networks for Large-Scale Image Recognition <https://arxiv.org/abs/1409.1556>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 553433881,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1499,7 +1518,7 @@
             "base_filename": "vgg19_bn-c79401a0.pth",
             "version": null,
             "description": "VGG-19 model from `Very Deep Convolutional Networks for Large-Scale Image Recognition <https://arxiv.org/abs/1409.1556>`_ with batch normalization trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 574769405,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1544,7 +1563,7 @@
             "base_filename": "vgg19-dcbb9e9d.pth",
             "version": null,
             "description": "VGG-19 model from `Very Deep Convolutional Networks for Large-Scale Image Recognition <https://arxiv.org/abs/1409.1556>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 574673361,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1589,7 +1608,7 @@
             "base_filename": "wide_resnet101_2-32ee1156.pth",
             "version": null,
             "description": "Wide ResNet-101-2 model from `Wide Residual Networks <https://arxiv.org/abs/1605.07146>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 254695146,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
@@ -1634,7 +1653,7 @@
             "base_filename": "wide_resnet50_2-95faca4d.pth",
             "version": null,
             "description": "Wide ResNet-50-2 model from `Wide Residual Networks <https://arxiv.org/abs/1605.07146>`_ trained on ImageNet",
-            "source": "https://pytorch.org/docs/stable/torchvision/models.html",
+            "source": "https://pytorch.org/vision/main/models.html",
             "size_bytes": 138223492,
             "manager": {
                 "type": "fiftyone.core.models.ModelManager",
diff --git a/fiftyone/zoo/models/torch.py b/fiftyone/zoo/models/torch.py
index 7ed2f2d07ef..148792b5f56 100644
--- a/fiftyone/zoo/models/torch.py
+++ b/fiftyone/zoo/models/torch.py
@@ -24,28 +24,38 @@ class TorchvisionImageModelConfig(
     """Configuration for running a :class:`TorchvisionImageModel`.
 
     Args:
-        model_name (None): the name of a zoo model containing a state dict to
-            load
-        model_path (None): the path to a state dict on disk to load
-        entrypoint_fcn: a string like ``"torchvision.models.inception_v3"``
-            specifying the entrypoint function that loads the model
+        entrypoint_fcn: a fully-qualified function string like
+            ``"torchvision.models.inception_v3"`` specifying the entrypoint
+            function that loads the model
         entrypoint_args (None): a dictionary of arguments for
             ``entrypoint_fcn``
         output_processor_cls: a string like
             ``"fifytone.utils.torch.ClassifierOutputProcessor"`` specifying the
             :class:`fifytone.utils.torch.OutputProcessor` to use
         output_processor_args (None): a dictionary of arguments for
-            ``output_processor_cls(class_labels, **kwargs)``
+            ``output_processor_cls(classes=classes, **kwargs)``
+        confidence_thresh (None): an optional confidence threshold apply to any
+            applicable predictions generated by the model
         labels_string (None): a comma-separated list of the class names for the
-            model
-        labels_path (None): the path to the labels map for the model
-        use_half_precision (None): whether to use half precision
+            model, if applicable
+        labels_path (None): the path to the labels map for the model, if
+            applicable
+        mask_targets (None): a mask targets dict for the model, if applicable
+        mask_targets_path (None): the path to a mask targets map for the model,
+            if applicable
+        skeleton (None): a keypoint skeleton dict for the model, if applicable
         image_min_size (None): resize the input images during preprocessing, if
             necessary, so that the image dimensions are at least this
             ``(width, height)``
         image_min_dim (None): resize input images during preprocessing, if
             necessary, so that the smaller image dimension is at least this
             value
+        image_max_size (None): resize the input images during preprocessing, if
+            necessary, so that the image dimensions are at most this
+            ``(width, height)``
+        image_max_dim (None): resize input images during preprocessing, if
+            necessary, so that the largest image dimension is at most this
+            value.
         image_size (None): a ``(width, height)`` to which to resize the input
             images during preprocessing
         image_dim (None): resize the smaller input dimension to this value
@@ -54,8 +64,14 @@ class TorchvisionImageModelConfig(
             preprocessing the input images
         image_std (None): a 3-array of std values in ``[0, 1]`` for
             preprocessing the input images
+            inputs that are lists of Tensors
         embeddings_layer (None): the name of a layer whose output to expose as
-            embeddings
+            embeddings. Prepend ``"<"`` to save the input tensor instead
+        use_half_precision (None): whether to use half precision (only
+            supported when using GPU)
+        cudnn_benchmark (None): a value to use for
+            :attr:`torch:torch.backends.cudnn.benchmark` while the model is
+            running
     """
 
     def __init__(self, d):
diff --git a/setup.py b/setup.py
index 5bb3c187709..1f2ac2e8a74 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@
 from setuptools import setup, find_packages
 
 
-VERSION = "0.15.0.1"
+VERSION = "0.15.1"  # changed by https://github.com/voxel51/fiftyone/pull/1601
 
 
 def get_version():
diff --git a/tests/unittests/dataset_tests.py b/tests/unittests/dataset_tests.py
index 783083f2820..164cf1c5c1f 100644
--- a/tests/unittests/dataset_tests.py
+++ b/tests/unittests/dataset_tests.py
@@ -1084,8 +1084,9 @@ def test_classes(self):
         dataset = fo.Dataset()
 
         default_classes = ["cat", "dog"]
-
         dataset.default_classes = default_classes
+
+        dataset.reload()
         self.assertListEqual(dataset.default_classes, default_classes)
 
         with self.assertRaises(Exception):
@@ -1098,6 +1099,8 @@ def test_classes(self):
         classes = {"ground_truth": ["cat", "dog"]}
 
         dataset.classes = classes
+
+        dataset.reload()
         self.assertDictEqual(dataset.classes, classes)
 
         with self.assertRaises(Exception):
@@ -1119,8 +1122,9 @@ def test_mask_targets(self):
         dataset = fo.Dataset()
 
         default_mask_targets = {1: "cat", 2: "dog"}
-
         dataset.default_mask_targets = default_mask_targets
+
+        dataset.reload()
         self.assertDictEqual(
             dataset.default_mask_targets, default_mask_targets
         )
@@ -1133,8 +1137,9 @@ def test_mask_targets(self):
         dataset.save()  # success
 
         mask_targets = {"ground_truth": {1: "cat", 2: "dog"}}
-
         dataset.mask_targets = mask_targets
+
+        dataset.reload()
         self.assertDictEqual(dataset.mask_targets, mask_targets)
 
         with self.assertRaises(Exception):
@@ -1142,13 +1147,13 @@ def test_mask_targets(self):
             dataset.save()  # error
 
         dataset.mask_targets.pop("hi")
+        dataset.save()  # success
 
         with self.assertRaises(Exception):
             dataset.mask_targets[1] = {1: "cat", 2: "dog"}
             dataset.save()  # error
 
         dataset.mask_targets.pop(1)
-
         dataset.save()  # success
 
         with self.assertRaises(Exception):
@@ -1165,6 +1170,78 @@ def test_mask_targets(self):
         dataset.mask_targets.pop("predictions")
         dataset.save()  # success
 
+    @drop_datasets
+    def test_skeletons(self):
+        dataset = fo.Dataset()
+
+        default_skeleton = fo.KeypointSkeleton(
+            labels=["left eye", "right eye"], edges=[[0, 1]]
+        )
+        dataset.default_skeleton = default_skeleton
+
+        dataset.reload()
+        self.assertEqual(dataset.default_skeleton, default_skeleton)
+
+        with self.assertRaises(Exception):
+            dataset.default_skeleton.labels = [1]
+            dataset.save()  # error
+
+        dataset.default_skeleton.labels = ["left eye", "right eye"]
+        dataset.save()  # success
+
+        with self.assertRaises(Exception):
+            dataset.default_skeleton.edges = "hello"
+            dataset.save()  # error
+
+        dataset.default_skeleton.edges = [[0, 1]]
+        dataset.save()  # success
+
+        dataset.default_skeleton.labels = None
+        dataset.save()  # success
+
+        skeletons = {
+            "ground_truth": fo.KeypointSkeleton(
+                labels=["left eye", "right eye"], edges=[[0, 1]]
+            )
+        }
+        dataset.skeletons = skeletons
+
+        dataset.reload()
+        self.assertDictEqual(dataset.skeletons, skeletons)
+
+        with self.assertRaises(Exception):
+            dataset.skeletons["hi"] = "there"
+            dataset.save()  # error
+
+        dataset.skeletons.pop("hi")
+        dataset.save()  # success
+
+        with self.assertRaises(Exception):
+            dataset.skeletons[1] = fo.KeypointSkeleton(
+                labels=["left eye", "right eye"], edges=[[0, 1]]
+            )
+            dataset.save()  # error
+
+        dataset.skeletons.pop(1)
+        dataset.save()  # success
+
+        with self.assertRaises(Exception):
+            dataset.skeletons["ground_truth"].labels = [1]
+            dataset.save()  # error
+
+        dataset.skeletons["ground_truth"].labels = ["left eye", "right eye"]
+        dataset.save()  # success
+
+        with self.assertRaises(Exception):
+            dataset.skeletons["ground_truth"].edges = "hello"
+            dataset.save()  # error
+
+        dataset.skeletons["ground_truth"].edges = [[0, 1]]
+        dataset.save()  # success
+
+        dataset.skeletons["ground_truth"].labels = None
+        dataset.save()  # success
+
     @drop_datasets
     def test_dataset_info_import_export(self):
         dataset = fo.Dataset()
@@ -1177,6 +1254,15 @@ def test_dataset_info_import_export(self):
         dataset.mask_targets = {"ground_truth": {1: "cat", 2: "dog"}}
         dataset.default_mask_targets = {1: "cat", 2: "dog"}
 
+        dataset.skeletons = {
+            "ground_truth": fo.KeypointSkeleton(
+                labels=["left eye", "right eye"], edges=[[0, 1]]
+            )
+        }
+        dataset.default_skeleton = fo.KeypointSkeleton(
+            labels=["left eye", "right eye"], edges=[[0, 1]]
+        )
+
         with etau.TempDir() as tmp_dir:
             json_path = os.path.join(tmp_dir, "dataset.json")
 
@@ -1186,15 +1272,18 @@ def test_dataset_info_import_export(self):
             self.assertDictEqual(dataset2.info, dataset.info)
 
             self.assertDictEqual(dataset2.classes, dataset.classes)
-            self.assertListEqual(
-                dataset2.default_classes, dataset.default_classes
-            )
+            self.assertEqual(dataset2.default_classes, dataset.default_classes)
 
             self.assertDictEqual(dataset2.mask_targets, dataset.mask_targets)
-            self.assertDictEqual(
+            self.assertEqual(
                 dataset2.default_mask_targets, dataset.default_mask_targets
             )
 
+            self.assertDictEqual(dataset2.skeletons, dataset.skeletons)
+            self.assertEqual(
+                dataset2.default_skeleton, dataset.default_skeleton
+            )
+
         with etau.TempDir() as tmp_dir:
             dataset_dir = os.path.join(tmp_dir, "dataset")
 
@@ -1206,15 +1295,18 @@ def test_dataset_info_import_export(self):
             self.assertDictEqual(dataset3.info, dataset.info)
 
             self.assertDictEqual(dataset3.classes, dataset.classes)
-            self.assertListEqual(
-                dataset3.default_classes, dataset.default_classes
-            )
+            self.assertEqual(dataset3.default_classes, dataset.default_classes)
 
             self.assertDictEqual(dataset3.mask_targets, dataset.mask_targets)
-            self.assertDictEqual(
+            self.assertEqual(
                 dataset3.default_mask_targets, dataset.default_mask_targets
             )
 
+            self.assertDictEqual(dataset3.skeletons, dataset.skeletons)
+            self.assertEqual(
+                dataset3.default_skeleton, dataset.default_skeleton
+            )
+
 
 class DatasetDeletionTests(unittest.TestCase):
     @drop_datasets


Code review comments:
diff_hunk: @@ -341,3 +343,115 @@ def classification_to_detections(sample_collection, in_field, out_field):
             image[out_field] = fol.Detections(detections=[detection])
 
         sample.save()
+
+
+def filter_keypoints(sample_collection, field, expr=None, labels=None):
path: fiftyone/utils/labels.py
text: @user1:
Shouldn't this proivde an `only_matches` option? If the confidence filter does not filter empty keypoints then it is better to do this all client side in the App.

@author:
Hmm yeah that does make sense. If a filter omits all points in a `Keypoint`, remove the entire object.

I think I can add that

@author:
@user1 ok added the `only_matches` flag.

I'm thinking about converting this utility to a proper `FilterKeypoints(ViewStage)` and `SampleCollection.filter_keypoints()` view stage. Any objections?

@user1:
I might prefer `FilterLabels` to support keypoints automatically. Perhaps in addition to `FilterKeypoints`. `FilterKeypoints` is somewhat specific but I suppose thats fine for now. 

@author:
I thought about merging this functionality into `FilterLabels`, but the problem is that will require introducing new optional kwargs, say `points_filter` and `points_labels`, that are only applicable when the field being filtered happens to be of type `Keypoint(s)`.

The filtering done by this `filter_keypoints()` utility is unique in that it operates at an extra level of nesting that is not applicable to any other field types.

However, the standard filtering operations from `filter_labels()` are still relevant to `Keypoint(s)` fields, if desired. EG filtering by tags, `Keypoint.label`, etc.

@user1:
Ok. Cause to refactor `Keypoint` in the future