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
ultralyticsandopencv-pythoninstalled:pip install ultralytics opencv-python. -
Your own trained OBB weights.
yolov8n-obb.ptas shipped knows only its general pretrained classes, not your notches — pointYOLO()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
- Load the model and open the camera.
names = model.namesgives the class-name dictionary your training defined. - 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. - Read a frame, run
model.predict(frame, conf=0.25, verbose=False)and takeresults[0]. - Stamp the frame once, before the box loop: all 33 notches in one image share one capture time.
- 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 withnames[int(b.cls)]andfloat(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.
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
framevalue equals the box count the overlay drew — 33 here. - Every
confidencesits between 0.25 and 1.0. - Every
angle_degmatches 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. Iterateresult.obb. -
save_txt=Trueinstead 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}"printstensor(0.8123)into the file. Convert withfloat(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.
- LAN ports
- 3-4 LAN ports
- USB 3 ports
- 2 USB 3 ports
- Light outputs
- 4 built-in channels
- Memory
- 8 GB
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.
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
- Light outputs
- 4 built-in channels
- Memory
- 8 GB
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.
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
- PoE
- PoE camera ports
- Light outputs
- 8 built-in channels
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.


