YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

Earring Post Angle Pipeline

Batch-measures the tilt angle of the post on flat-lay earring photos (plain/white background).

How it works

  1. Segments the earring from the background. Auto-detects whether the background is light (dark earring on white) or dark (bright/ reflective earring on matte black) by sampling the image border, and thresholds accordingly.
  2. Applies CLAHE local-contrast enhancement before thresholding, then closes gaps in the mask, so uneven metallic reflectance β€” specular highlights and shadowed facets from studio lighting β€” doesn't fragment a thin post into disconnected pieces.
  3. Skeletonizes the silhouette and finds the post using its topology, not just its thickness: a closed hoop/knot-shaped body's skeleton has no free ends, so any dangling endpoint-terminated branch is a candidate post. This is what makes it work on wire-frame hoop/knot designs where the post is roughly the same wire gauge as the body β€” a thickness-only heuristic would fail there.
  4. Scores each candidate branch by straightness (a post is straight; hoop/ knot body segments are curved), length, and how thin its tip is relative to the body's thickest point (filters out skeleton artifacts that can appear near a solid body's center on rounder stud designs). Straightness is weighted leniently -- a real post can be BENT as a defect, and the goal is to still find and measure it correctly, not penalize it out of consideration in favor of some straighter false candidate.
  5. Measures the angle as the straight-line vector from the post's base (where it meets the body) to its tip -- NOT a line fit through every pixel along the path. This matters for a bent post: a line fit (e.g. PCA) through a bent path would blend the bend into a misleading average direction, or even wash it out entirely. The base->tip vector directly captures where the tip actually ends up, which is what matters for the defect, regardless of the exact shape of the bend in between.
    • 0Β° = post points straight up
    • positive = tip leans right
    • negative = tip leans left

Validated on three synthetic test suites at known angles (0°–45Β°):

  • White background, solid-bodied stud earrings, including noisy/off-white/ JPEG conditions.
  • Matte black background with simulated side-lighting (specular highlights and uneven shading across the metal, including partial "shadow gaps" in the post).
  • Matte black background, wire-frame hoop/knot bodies (interlocking rings) with a straight post β€” matching the earring style you shared.

All three suites tracked ground truth within ~1–3Β°, with one known edge case documented below.

Usage

pip install opencv-python scikit-image scipy numpy --break-system-packages
python earring_post_angle.py --input_dir /path/to/photos --output_dir /path/to/results

Output

  • results/angles.csv β€” filename, angle_deg, post_length_px, notes
  • results/annotated/*.png β€” each photo with the detected post line drawn on it (green line + angle label), so you can spot-check accuracy at a glance

Visualizing What Was Detected (body vs. post, and bent posts)

Every annotated output image now shows three things distinctly, so you can see exactly what the algorithm found instead of just a single number:

  • Blue tint β€” the region classified as "the body" (exactly what the rotation-invariant angle's centroid calculation uses).
  • Red line β€” the actual traced path of the post, following its real shape pixel-by-pixel. If the post is bent, the bend is visible here.
  • Green arrow β€” the straight-line measurement actually used for angle_deg/relative_angle_deg: a direct vector from the post's base to its tip. Comparing the green arrow to the red path shows you the bend at a glance β€” if they closely overlap, the post is straight; if they diverge, that gap is the bend.

The results also include a post_straightness value (1.0 = perfectly straight, lower = more bent) and an on-image "BENT" flag when straightness drops below 0.97 β€” useful as a defect signal in its own right, separate from the angle numbers, since a post can be bent partway along in a way that doesn't fully show up in the net tip-angle alone.

I validated this against a synthetic bent-post test (straight for the first half, then bending by a known angle for the second half) β€” the algorithm correctly measured the net tip deviation in each case (e.g. a 60Β° bend at the midpoint correctly read as ~30Β° net deviation, matching the geometry exactly), and the drawn red path visibly showed the kink at the bend point.

Batch Comparison Against a Known-Good Sample

(Any Orientation)

If your batch samples are loose parts placed in any position/rotation (not a fixed fixture), use --reference_image:

python earring_post_angle.py --input_dir batch/ --output_dir results/ \
    --reference_image good_sample.jpg --tolerance 5.0

This measures the known-good sample once, then compares every batch image against it using a rotation-invariant angle: the post's direction relative to the earring's own body geometry (specifically, relative to the line from the body's centroid out to where the post attaches), rather than relative to the image's vertical axis. Since the post and the body rotate together as one rigid part, this relationship stays the same no matter how the part is rotated on the tray β€” a good part scores the same whether it's photographed right-side up or spun around.

angles.csv gains extra columns: diff_from_reference_deg and (if --tolerance is set) pass_fail.

Requirement: this only works correctly when the post attaches at the earring body's edge/surface (true for hoop, knot, and most real stud designs) β€” not for a body where the post would run straight through the geometric center.

Stencil Batch Mode (manual registration, for low-contrast/noisy scenes)

If automatic segmentation struggles on your parts (e.g. a thin post with weak contrast against the background -- see the Debugging section on diagnosing this), this mode leans on human vision instead: you drag the calibrated body outline to align with each sample by eye, and only the narrow, aligned, masked region gets auto-detected -- avoiding the whole-image noise that causes wrong-branch selection.

# First, calibrate normally (fixed-position mode, NOT --any_orientation):
python live_camera_angle.py --calibrate

# Then run the stencil batch workflow:
python live_camera_angle.py --stencil_batch --tolerance 5.0

Workflow:

  1. A yellow outline (the calibrated body shape) appears on the live feed, with an orange rotation handle sticking out from it.
  2. Drag the yellow outline to move it over a sample; drag the orange handle to rotate it until the outline visually matches that sample's actual orientation.
  3. Press m (or spacebar) to measure. Behind the scenes: the region under the aligned stencil is cropped, rotated back to the calibration's original orientation, masked to just the body outline, and measured β€” directly comparable to the calibration's reference angle.
  4. Repeat for each sample in the batch (drag/rotate, press m). Press r to reset the stencil to center, s to save a snapshot.
  5. Press q/ESC when done β€” writes a summary CSV (default stencil_batch_results.csv, change with --output_csv) with each sample's stencil angle used, detected angle, diff from reference, and pass/fail if --tolerance was set.

I validated the underlying math with a controlled test: measuring a rotated copy of the same physical part came back within ~0.6Β° of zero at every rotation angle tested (0°–170Β°), confirming the crop/rotate-back/ mask math correctly cancels out the manual rotation and isolates the real angular defect.

IMPORTANT -- calibrate with a generously-sized outline: if the calibrated body outline/polygon only extends a short distance along the post (just enough to cover a straight reference sample), a BENT post on a different sample can bend beyond that boundary -- the mask flattens everything past it to background, so the detector never sees the bent portion at all. This can silently produce a false PASS on a genuinely bent part, since it only measures the short straight stub that's actually visible to it.

Fix: when calibrating, extend the polygon well past the post's normal length -- with margin for a bend in any direction, not just tracing the reference sample's post tightly.

Safeguard: the tool now automatically checks whether the detected tip lands suspiciously close to the polygon/mask boundary, and if so, flags it clearly:

  • The live stencil HUD shows an orange warning ("post tip near mask boundary -- may be clipped") instead of a clean green PASS.
  • The saved CSV's notes column records the same warning for any measurement affected.

If you see this warning, don't trust that reading -- recalibrate with a larger outline first.

Requirement: the calibration must be fixed-position mode (drawn with --calibrate, not --calibrate --any_orientation) since this mode reuses its ROI polygon and reference angle directly.

Debugging

Both the batch script and the live camera script support --debug for detailed per-image/per-frame diagnostics:

python earring_post_angle.py --input_dir photos/ --output_dir results/ --debug
python live_camera_angle.py --any_orientation --debug

This prints: background detection (light/dark) and mask stats, every candidate post branch considered with its straightness/length/tip-ratio score (and why any were rejected), the winning branch's geometry, the centroid/vectors behind the rotation-invariant angle calculation, and (live only) camera settings actually applied, raw-vs-smoothed values each frame with outlier flagging, FPS, and calibration save/load contents. Useful for understanding why a particular image or frame got the angle it did, rather than just seeing the final number.

Live Basler Camera Feed

live_camera_angle.py shows a real-time video window with the detected post angle overlaid, using Basler's pypylon SDK. It supports two modes:

1. Absolute mode (no calibration) β€” shows each sample's angle from vertical, same as the batch pipeline.

2. Reference comparison mode β€” calibrate once against a known-good sample in your fixture, then every subsequent sample (placed in the same ROI/fixture position) is compared against that reference and shown as a difference, with an optional pass/fail tolerance.

pip install pypylon --break-system-packages   # if not already installed

# First time: calibrate against a known-good sample
python live_camera_angle.py --calibrate --tolerance 3.0

# After that, just run it (loads the saved calibration automatically)
python live_camera_angle.py --tolerance 3.0

Calibration workflow (triggered by --calibrate, or press c any time while running) depends on the mode:

Fixed-position mode (default) β€” for parts placed in the same fixture/ROI every time:

  1. A frame is grabbed and a window pops up showing an auto-detected outline around the earring body, with draggable points on it. Adjust it to match the body precisely:

    • drag any point to move it
    • double-click an edge to add a new point there
    • right-click a point to remove it (minimum 3 points)
    • press r to reset to the auto-detected outline, ENTER to confirm, ESC to cancel

    Anything inside the bounding box but outside this outline gets flattened to background before detection runs β€” useful if another part or clutter sits near the earring in frame.

  2. A zoomed view of that ROI pops up β€” click the post's TIP (the far end, away from the body), then click the BASE (where the post meets the body). A line and its angle are shown; press ENTER to accept, r to redo the two clicks, or ESC to cancel.

  3. This ROI + reference angle is saved to calibration.json.

Any-orientation mode (--any_orientation) β€” for loose parts placed anywhere, at any rotation:

python live_camera_angle.py --calibrate --any_orientation --tolerance 5.0
  1. Place the known-good sample anywhere in frame and a frame is grabbed.
  2. Same drawing tools as fixed-position mode: adjust the auto-detected outline (drag points, double-click an edge to add one, right-click to remove), press ENTER, then click the post's TIP then its BASE.
  3. From that precise manual input, a single rotation-invariant reference angle is computed and saved.

Worth knowing: the reference itself is measured manually (by you, once, carefully) so it's as accurate as possible β€” it's the baseline every batch sample gets judged against. Batch samples afterward are still fully automatic and can be in any position/rotation; only the one-time reference calibration involves drawing.

From then on, place each sample anywhere in frame at any rotation β€” the overlay shows Ref XΒ° | Sample YΒ° | Diff ZΒ° (plus PASS/FAIL with --tolerance) the same as fixed-position mode, but using the rotation-invariant angle so orientation on the tray doesn't matter. Press c any time to recalibrate; remember to add --any_orientation again next time you launch, or it'll load a fixed-ROI calibration by default.

Other options:

  • --serial 22928445 β€” pick a specific camera if you have more than one
  • --exposure 8000 β€” set exposure time in microseconds
  • --scale 0.5 β€” run detection on a downscaled copy for speed (in reference mode, this downscales just the ROI crop, not the whole frame, so it's rarely needed there)
  • --every 2 β€” only run detection every Nth frame while still displaying every frame live
  • --calibration_path myline.json β€” use a different calibration file (handy if you run multiple product lines/fixtures)
  • --debug β€” print per-frame diagnostics to the console: which camera settings actually got applied (exposure/gain lock), each detection frame's raw vs. smoothed angle (flagging any reading more than 15Β° from the smoothed value as a likely branch-flip outlier), FPS, and recalibration events. Useful for confirming whether jitter is a real detection problem or already being absorbed by smoothing.

Controls in the video window: q/ESC quit, s save the current annotated frame to ./snapshots/, c (re)calibrate.

If the live reading jumps around erratically frame to frame (rather than being a small jitter), two things usually fix it:

  1. Lock exposure/gain. The script now disables ExposureAuto and GainAuto automatically on startup (best-effort β€” not all camera models expose these nodes) and prints a note if you didn't also pass --exposure. A lingering auto-exposure adjustment shifts frame brightness slightly each frame, which can shift the segmentation threshold enough to flip which part of the earring gets picked as "the post." Passing a fixed --exposure <microseconds> value locks this down further.
  2. Smoothing. The displayed angle is now the median of the last --smoothing_window frames (default 7), not just the latest single frame β€” this absorbs an occasional bad frame (e.g. detection briefly latching onto the wrong skeleton branch) without it showing up as a visible jump, while still updating quickly once a new value is consistent. Use --smoothing_window 1 to see raw per-frame values (for debugging), or raise it (e.g. 15) for an even steadier reading at the cost of slightly slower response to a genuine change.

Important: I wrote and validated this against pypylon's documented API and tested the detection, calibration, ROI-cropping, and diff-calculation logic extensively offline (including simulating the full calibrate β†’ compare workflow against test images), but I don't have access to physical Basler hardware in this environment, so the camera grab loop itself hasn't been tested end-to-end. Please try it on your machine β€” if pypylon throws anything unexpected (pixel format, timeout behavior, etc. can vary by camera model/firmware), send me the error and I'll fix it.

Notes / tuning

  • Assumes one earring per image, photographed flat on a plain background (light or dark both work), with the post visible and not overlapping the body/hoop in a way that hides its full length.
  • Works for both solid-bodied stud earrings AND wire-frame hoop/knot designs where the post is a similar wire gauge to the body.
  • CONFIG["background"]: "auto" (default), or force "light"/"dark" if auto-detection misjudges your setup.
  • CONFIG["min_post_straightness"] (default 0.85): how straight a branch must be to count as a post. Lower this if your post itself has an expected slight curve; raise it if a curved part of a hoop/knot is being mistaken for the post.
  • CONFIG["max_tip_thickness_ratio"] (default 0.65): filters out skeleton artifacts that can appear near a solid body's center (common on rounder stud designs) by requiring a real post's tip to be meaningfully thinner than the body's single thickest point.
  • CONFIG["morph_close_kernel"] (default 11): bridges gaps in the mask β€” bump this up if a post is getting cut into disconnected segments by a specular highlight or shadow.
  • CONFIG["morph_open_kernel"] (default 3): removes small noise specks. Keep this smaller than your post's pixel width, or it will erode the post away entirely and break detection.
  • If posts aren't being found (notes column says so), try raising min_post_pixels if noise is being picked up as a post, or check the annotated image to see what the mask actually captured.
  • Known limitation: a severe, near-total brightness dropout partway along the post (e.g. a deep shadow nearly matching the background over a large fraction of its length) can fragment the post in the mask and confuse detection. This is more of a risk with strong side lighting than with even front lighting, but if you see it in practice, try increasing morph_close_kernel further first.
  • Recommend spot-checking a sample of annotated/ images against angles.csv before trusting the pipeline on a full production batch β€” especially the first run on your actual photos, since real lighting and design variation is always messier than any synthetic test can fully capture.
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support