System designHow-to

Save YOLO OBB detections to CSV with timestamp and confidence

result.boxes is None on an OBB model. Iterate result.obb and save one timestamped CSV row per detection, with class name and confidence.

LumaSpec Vision9 min read Share on LinkedIn

On this page 11 sections
  1. 01Where OBB detections actually live
  2. 02What you need
  3. 03The logging loop, step by step
  4. 04What each CSV column stores
  5. 05A timestamp format that sorts and parses
  6. 06Worked example: how much one shift writes
  7. 07Verify the log matches the screen
  8. 08Common mistakes
  9. 09Where to run the loop
  10. 10When a CSV is not the right output
  11. 11Questions

Your OBB model finds all 33 notches, draws every box on screen, and then the CSV logger dies with 'NoneType' object is not iterable — or writes nothing at all. The cause is not the confidence threshold or the camera: an oriented-bounding-box model returns its detections in a different attribute than an axis-aligned model, and logging code written for plain detection reads the empty one. This guide shows where OBB detections live, gives the logging loop that writes one timestamped CSV row per notch, settles the timestamp format, and works out how much disk a full shift of 33-notch frames fills. The same loop runs on a bench PC or an x86 vision controller.

Where OBB detections actually live

model.predict(frame) returns a list with one Results object per processed image, and results[0] is your frame. A plain detection model puts its boxes in result.boxes. An OBB model instead fills result.obb, an object of the same kind carrying .conf, .cls and the rotated geometry in .xywhr. The axis-aligned result.boxes stays None, which is exactly what a for box in result.boxes: loop trips over: TypeError: 'NoneType' object is not iterable.

That also explains why lowering conf changed nothing — the loop was reading an attribute no OBB model ever populates, while result.plot() drew correctly from the OBB data the model did produce. If the one-result-per-frame structure itself feels shaky, the YOLO demo output walkthrough unpacks that first.

What you need

  • Python 3.9 or newer with ultralytics and opencv-python installed: pip install ultralytics opencv-python.
  • Your own trained OBB weights. yolov8n-obb.pt as shipped knows only its general pretrained classes, not your notches — point YOLO() at the weights your training run produced.
  • A capture device. cv2.VideoCapture(0) opens the first USB camera by index; frames from area scan cameras reach the same loop through the camera SDK or a GenICam layer, so only the capture line changes.
  • About a gigabyte of free disk per shift at the rates worked out below.

The logging loop, step by step

  1. Load the model and open the camera. names = model.names gives the class-name dictionary your training defined.
  2. Open the CSV in append mode with newline="" — without it, Excel shows a blank row between records on Windows. Write the header only when the file is new, so restarts append instead of wiping the previous shift.
  3. Read a frame, run model.predict(frame, conf=0.25, verbose=False) and take results[0].
  4. Stamp the frame once, before the box loop: all 33 notches in one image share one capture time.
  5. Guard with if result.obb is not None: — a frame with no detections above the threshold produces no rows, which is correct output. Then iterate the OBB object and write one row per box with names[int(b.cls)] and float(b.conf).
import csv
import math
import os
from datetime import datetime

import cv2
from ultralytics import YOLO

HEADER = ["timestamp", "frame", "class", "confidence",
          "cx_px", "cy_px", "w_px", "h_px", "angle_deg"]

path = "notches.csv"              # str: your log file
new_file = not os.path.exists(path) or os.path.getsize(path) == 0

model = YOLO("notch_obb.pt")      # your trained OBB weights
names = model.names               # {0: "notch", ...} from training
cap = cv2.VideoCapture(0)         # first USB camera

frame_id = 0                      # int: counts processed frames
with open(path, "a", newline="") as f:
    writer = csv.writer(f)
    if new_file:
        writer.writerow(HEADER)
    while True:
        ok, frame = cap.read()
        if not ok:
            break
        result = model.predict(frame, conf=0.25, verbose=False)[0]
        stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
        if result.obb is not None:          # OBB models put detections here
            for b in result.obb:
                x, y, w, h, r = [float(v) for v in b.xywhr]
                writer.writerow([stamp, frame_id, names[int(b.cls)],
                                 f"{float(b.conf):.3f}",
                                 f"{x:.1f}", f"{y:.1f}", f"{w:.1f}", f"{h:.1f}",
                                 f"{math.degrees(r):.2f}"])
        frame_id += 1
        cv2.imshow("OBB log", result.plot())
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break

cap.release()
cv2.destroyAllWindows()

What each CSV column stores

Column Source Notes
timestamp one datetime.now() per frame "2025-01-16 10:12:13.450"
frame your frame_id counter groups the 33 rows that one image produced
class names[int(b.cls)] the class name from training, not the raw index
confidence float(b.conf) sits between the conf=0.25 floor and 1.0
cx_px, cy_px b.xywhr[0], b.xywhr[1] box centre in original-image pixels
w_px, h_px b.xywhr[2], b.xywhr[3] box width and height in pixels
angle_deg math.degrees(b.xywhr[4]) .xywhr reports radians; convert once, store degrees

b.xywhr[4] is the rotation of the box. A rectangle looks identical rotated by 180°, so a notch reporting 45.00° and one reporting 225.00° describe the same tilt — compare angles by the smaller difference when matching against a reference. If logged confidences come out lower than training gave you, match the preprocessing first; the logger records whatever the model scores.

A timestamp format that sorts and parses

The compact digit block — 20250116101213450 for 10:12:13.450 on 16 January 2025 — sorts correctly as text, but every parser needs custom slicing. Use the ISO form instead: datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]. The %f code prints six microsecond digits and the [:-3] slice keeps three, so milliseconds survive as .450. The result sorts correctly, pandas reads it with parse_dates=["timestamp"], and Excel understands it. Keep the compact form only when a downstream device demands fixed-width digits.

Worked example: how much one shift writes

Assume 33 detections per frame and a loop that processes one frame every 70 ms — 35 ms of inference plus capture, overlay and CSV writes — about 14 frames per second.

rows/s = 33 detections/frame × 14 frames/s = 462 rows/s rows/8 h = 462 × 3600 s × 8 h = 13,305,600 rows bytes ≈ 13,305,600 × 63 bytes/row ≈ 838 MB ≈ 0.8 GB per shift

The 63 bytes per row is the plain-text width of the nine columns above: a 23-character timestamp, four coordinates, class, score, angle and commas. Two decisions follow. Rotate the file hourly — a name like notches_20250116_10.csv from datetime.now().strftime("notches_%Y%m%d_%H.csv"), checked once per frame — so a damaged file costs an hour, not a shift. And treat Excel as a spot-check tool: a worksheet stops at 1,048,576 rows, which at 462 rows per second is about 38 minutes. If each part or cycle must land in its own directory instead, the per-run output folder guide covers the directory side.

Verify the log matches the screen

Run the loop on a frame where you can count the drawn boxes, then check three things:

n = 0 if result.obb is None else len(result.obb)
print(stamp, n)   # on the 33-notch disc, expect: 2025-01-16 10:12:13.450 33
  • The row count per frame value equals the box count the overlay drew — 33 here.
  • Every confidence sits between 0.25 and 1.0.
  • Every angle_deg matches the drawn box's tilt, up to the 180° rectangle symmetry.

A mismatch means the writer is not seeing the same result object the plotter is — in practice, a leftover result.boxes loop. Acting on every object in one image rather than the first is a separate step; the one-snapshot multi-object guide covers that logic.

Common mistakes

  • for box in result.boxes: on an OBB model. Raises 'NoneType' object is not iterable. Iterate result.obb.
  • save_txt=True instead of a CSV. It writes YOLO-format text lines with no timestamp or confidence column. Use it for labeling; the CSV-to-YOLO converter guide is the reverse direction.
  • open(path, "w") on every restart. Each launch wipes the previous shift. Append mode plus the header-if-new check keeps history.
  • Formatting tensors directly. f"{b.conf}" prints tensor(0.8123) into the file. Convert with float(b.conf) first.
  • Stamping inside the per-box loop. datetime.now() per row spreads one instant over 33 clock reads. Stamp once per frame.
  • Trusting the buffer. Rows sit in Python's file buffer until the file closes.

Where to run the loop

Run the logging loop where the frames already land: on an x86 vision controller mounted next to the camera. A nano OBB model at tens of milliseconds per frame is a single-core workload, leaving room for the overlay, the CSV writes and any message to the line controller. The built-in light channels on these controllers also take over driving the ring light, which keeps exposure identical across the shift — one less variable in the confidence scores.

Vision controllersHikrobot MV-VC2140-128G40-1T VC2100 Vision Controller, N97, 8GB+128G SSD+ 1THDDMV-VC2140-128G40-1T
LAN ports
3-4 LAN ports
USB 3 ports
2 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$1,679.00View partBuild a system with it

The entry tier carries one camera with a nano-sized model. The MV-VC2140-128G40-1T runs on an Intel N97 with 8 GB of memory and a 128 GB SSD plus a 1 TB HDD, and its 4 built-in light channels can drive the ring light directly.

Vision controllersHikrobot MV-VC3301H-128G66 I3-8100, 8GB+128G SSD, No PoE, PCIe expansion supported, Incl. VM6200Pro licenseMV-VC3301H-128G66
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$2,419.00View partBuild a system with it

Two to four streams, or vendor algorithm software alongside your Python loop, call for the next step. The MV-VC3301H-128G66 pairs an Intel Core i3-8100 with 8 GB of memory and a 128 GB SSD, supports PCIe expansion, and includes the VM6200Pro license.

Vision controllersHikrobot MV-VC5525-256G70-NN VC5000 Vision Controller, 12th-I5, 32GB+256G SSD, Supported PCIEMV-VC5525-256G70-NN
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
PoE
PoE camera ports
Light outputs
8 built-in channels
$4,479.00 · quotedView and quoteBuild a system with it

Several cameras with PoE cabling need the top of the range. The MV-VC5525-256G70-NN pairs a 12th-generation Intel Core i5 with 32 GB of memory and a 256 GB SSD and adds PoE camera ports and 8 built-in light channels.

To spec the whole station in one pass, start the configurator at the controller step; the team answers sizing questions through the contact page.

When a CSV is not the right output

Three cases call for rerouting the data. If the inspection can be trained into a smart camera's own toolset, the loop collapses into the camera — it decides and signals with no host PC; the guide to running custom object detection on embedded smart cameras covers that path, and the smart cameras range is where to browse.

If a robot consumes the results live, ship the parsed coordinates over a socket each cycle — the UR10e socket-string walkthrough shows the format end to end — and let the CSV remain the shift's history file.

And if the HMI is a desktop application rather than an OpenCV window, drawing the detections in a WinForms PictureBox keeps the overlay inside your own interface while the logging loop stays exactly as written above. Whichever output you choose, the results object you just learnt to read stays the same.

Questions

Why does result.plot() draw boxes while result.boxes is None?

plot() draws from the OBB data the model actually produced, so the overlay looks normal even though the boxes attribute stays None. The boxes object belongs to axis-aligned detection models; an OBB model never fills it, and a loop written for plain detection therefore crashes with 'NoneType' object is not iterable while the screen shows every notch. Read class, confidence and geometry from result.obb instead.

How do I convert the OBB angle to degrees?

Each box carries xywhr, whose fifth value is the rotation in radians. math.degrees converts in one call: math.degrees(1.5708) is 90.00 to two decimals. A rectangle reads the same tilt at angle and angle plus 180 degrees, so compare angles by the smaller difference when you match rows against a reference notch. Store degrees for reports and keep the raw radians column if trigonometry runs downstream.

Does predict(save_txt=True) replace the CSV?

No. save_txt writes YOLO-format plain-text lines, class number plus normalized coordinates, into a run folder; it carries no timestamp and no confidence column, which is why the log must be written yourself. Keep it for labeling data. For the production record, the csv.writer loop puts the frame time, the class name and the score you acted on into each row.

How many CSV rows can Excel open?

A worksheet stops at 1,048,576 rows. At 33 detections per frame and 14 processed frames per second that is 462 rows per second, so the limit arrives after about 38 minutes (1,048,576 ÷ 462 ≈ 2,270 s). Rotate hourly files and analyze with pandas or LibreOffice, which read millions of rows; keep Excel for spot checks.

What confidence threshold should the logger use?

Log at the threshold you act on: conf=0.25 matches the default and keeps marginal detections out of the record. If a later tuning pass needs more candidates, lower it and filter on the confidence column instead — the CSV stores every raw score, so re-tuning costs no new camera time. An empty frame writes no rows, which is correct output on a quiet scene, not a failure.