From c064f9f2d64aefbe8c058ebb8619657046cfb019 Mon Sep 17 00:00:00 2001 From: doctorfogarty Date: Sat, 9 May 2026 09:28:25 -0700 Subject: [PATCH 1/4] Added classes and settings for supporting the new ThriftiestOV9281 Camera --- .../vision/camera/CameraQuirk.java | 2 + .../vision/camera/QuirkyCamera.java | 4 +- .../ThriftyOV9281CameraSettables.java | 124 ++++++++++++++++++ .../camera/USBCameras/USBCameraSource.java | 3 + 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java diff --git a/photon-core/src/main/java/org/photonvision/vision/camera/CameraQuirk.java b/photon-core/src/main/java/org/photonvision/vision/camera/CameraQuirk.java index 191c2ca91e..775bcbadd1 100644 --- a/photon-core/src/main/java/org/photonvision/vision/camera/CameraQuirk.java +++ b/photon-core/src/main/java/org/photonvision/vision/camera/CameraQuirk.java @@ -52,4 +52,6 @@ public enum CameraQuirk { ArduOV9782, /** Camera has odd exposure range, and supports gain control */ See3Cam_24CUG, + /** Thrifty OV9281 */ + ThriftyOV9281Controls, } diff --git a/photon-core/src/main/java/org/photonvision/vision/camera/QuirkyCamera.java b/photon-core/src/main/java/org/photonvision/vision/camera/QuirkyCamera.java index c500592227..14de7c188a 100644 --- a/photon-core/src/main/java/org/photonvision/vision/camera/QuirkyCamera.java +++ b/photon-core/src/main/java/org/photonvision/vision/camera/QuirkyCamera.java @@ -87,7 +87,9 @@ public class QuirkyCamera { CameraQuirk.ArduOV9782Controls), // Innomaker OV9281 new QuirkyCamera( - 0x0c45, 0x636d, "USB Camera", "Innomaker OV9281", CameraQuirk.InnoOV9281Controls)); + 0x0c45, 0x636d, "USB Camera", "Innomaker OV9281", CameraQuirk.InnoOV9281Controls), + // Thrifty OV9281 + new QuirkyCamera(0x1BCF, 0x28C5, CameraQuirk.ThriftyOV9281Controls, CameraQuirk.Gain)); public static final QuirkyCamera DefaultCamera = new QuirkyCamera(0, 0, ""); public static final QuirkyCamera ZeroCopyPiCamera = diff --git a/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java new file mode 100644 index 0000000000..6e7ec2b9b7 --- /dev/null +++ b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) Photon Vision. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.photonvision.vision.camera.USBCameras; + +import java.util.HashMap; +import org.photonvision.common.configuration.CameraConfiguration; +import org.wpilib.util.PixelFormat; +import org.wpilib.vision.camera.UsbCamera; +import org.wpilib.vision.camera.VideoMode; + +public class ThriftyOV9281CameraSettables extends GenericUSBCameraSettables { + + // The Sunplus SPCA2688 ISP only accepts these discrete exposure values. + // Any other value produces a completely black frame in MJPEG mode. + // Values are in V4L2 exposure_time_absolute units (100µs each). + private static final int[] DISCRETE_EXPOSURES = { + 5, 10, 20, 39, 78, 156, 312, 625, 1250, 2500, 5000 + }; + + public ThriftyOV9281CameraSettables(CameraConfiguration configuration, UsbCamera camera) { + super(configuration, camera); + } + + @Override + public void onCameraConnected() { + super.onCameraConnected(); + + // Filter out YUYV modes. The Sunplus SPCA2688 ISP's MJPEG encoder + // breaks permanently if a YUYV↔MJPEG format switch occurs. + // Only MJPEG modes (120fps) are usable; YUYV modes are 5-30fps anyway. + HashMap mjpegOnly = new HashMap<>(); + int newIdx = 0; + for (var entry : videoModes.entrySet()) { + if (entry.getValue().pixelFormat == PixelFormat.kMJPEG) { + mjpegOnly.put(newIdx++, entry.getValue()); + } + } + if (!mjpegOnly.isEmpty()) { + videoModes = mjpegOnly; + logger.info("Filtered to " + mjpegOnly.size() + " MJPEG-only modes (YUYV removed)"); + } + } + + @Override + protected void setUpExposureProperties() { + autoExposureProp = findProperty("exposure_auto", "auto_exposure").orElse(null); + exposureAbsProp = + findProperty("raw_exposure_time_absolute", "raw_exposure_absolute").orElse(null); + + // Expose the discrete range bounds to the PV UI slider. + this.minExposure = DISCRETE_EXPOSURES[0]; + this.maxExposure = DISCRETE_EXPOSURES[DISCRETE_EXPOSURES.length - 1]; + } + + @Override + public void setAllCamDefaults() { + // Disable continuous autofocus BEFORE super tries to set focus_absolute + softSet("focus_automatic_continuous", 0); + super.setAllCamDefaults(); + logger.info("Setting All Cam Defaults :: ThriftyOV9281"); + softSet("focus_absolute", 0); + } + + @Override + public void setAutoExposureImpl(boolean cameraAutoExposure) { + logger.debug("Setting auto exposure :: ThriftyOV9281 :: " + cameraAutoExposure); + if (autoExposureProp != null) { + autoExposureProp.set( + cameraAutoExposure ? PROP_AUTO_EXPOSURE_ENABLED : PROP_AUTO_EXPOSURE_DISABLED); + } + if (!cameraAutoExposure) { + setExposureRaw(this.lastExposureRaw); + } + } + + @Override + public void setExposureRaw(double exposureRaw) { + if (exposureRaw >= 0.0 && exposureAbsProp != null) { + int snapped = snapToDiscreteExposure((int) Math.round(exposureRaw)); + logger.debug( + "ThriftyOV9281: Setting " + + exposureAbsProp.getName() + + " to " + + snapped + + " (requested " + + exposureRaw + + ")"); + exposureAbsProp.set(snapped); + this.lastExposureRaw = snapped; + } + } + + /** + * Snap a requested exposure value to the nearest valid discrete value. The Sunplus SPCA2688 ISP + * only responds to specific exposure values; any other value produces a completely black frame. + */ + private static int snapToDiscreteExposure(int requested) { + int closest = DISCRETE_EXPOSURES[0]; + int minDist = Math.abs(requested - closest); + for (int discrete : DISCRETE_EXPOSURES) { + int dist = Math.abs(requested - discrete); + if (dist < minDist) { + minDist = dist; + closest = discrete; + } + } + return closest; + } +} diff --git a/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/USBCameraSource.java b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/USBCameraSource.java index c25b8a3781..c17cba6be2 100644 --- a/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/USBCameraSource.java +++ b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/USBCameraSource.java @@ -150,6 +150,9 @@ protected GenericUSBCameraSettables createSettables( settables = new InnoOV9281CameraSettables(config, camera); } else if (quirks.hasQuirk(CameraQuirk.See3Cam_24CUG)) { settables = new See3Cam24CUGSettables(config, camera); + } else if (quirks.hasQuirk(CameraQuirk.ThriftyOV9281Controls)) { + logger.debug("Using Thrifty OV9281 Settables"); + settables = new ThriftyOV9281CameraSettables(config, camera); } else { logger.debug("Using Generic USB Cam Settables"); settables = new GenericUSBCameraSettables(config, camera); From 82c09f002dd6da33645f449b8095658fe143e8d4 Mon Sep 17 00:00:00 2001 From: Judson James Date: Sat, 13 Jun 2026 00:48:41 -0400 Subject: [PATCH 2/4] Updated ThriftiestCam to support latest firmware changes --- .../ThriftyOV9281CameraSettables.java | 59 +++---------------- 1 file changed, 7 insertions(+), 52 deletions(-) diff --git a/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java index 6e7ec2b9b7..c9655b28c3 100644 --- a/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java +++ b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java @@ -25,15 +25,15 @@ public class ThriftyOV9281CameraSettables extends GenericUSBCameraSettables { - // The Sunplus SPCA2688 ISP only accepts these discrete exposure values. - // Any other value produces a completely black frame in MJPEG mode. - // Values are in V4L2 exposure_time_absolute units (100µs each). - private static final int[] DISCRETE_EXPOSURES = { - 5, 10, 20, 39, 78, 156, 312, 625, 1250, 2500, 5000 - }; - public ThriftyOV9281CameraSettables(CameraConfiguration configuration, UsbCamera camera) { super(configuration, camera); + + // Fix the exposure lower and upper limits + // The minimum usable exposure is above the UI default of 20, so + // the user is expected to increase exposure until the camera can + // pick up an image correctly. + this.minExposure = 1; + this.maxExposure = 2400; } @Override @@ -56,17 +56,6 @@ public void onCameraConnected() { } } - @Override - protected void setUpExposureProperties() { - autoExposureProp = findProperty("exposure_auto", "auto_exposure").orElse(null); - exposureAbsProp = - findProperty("raw_exposure_time_absolute", "raw_exposure_absolute").orElse(null); - - // Expose the discrete range bounds to the PV UI slider. - this.minExposure = DISCRETE_EXPOSURES[0]; - this.maxExposure = DISCRETE_EXPOSURES[DISCRETE_EXPOSURES.length - 1]; - } - @Override public void setAllCamDefaults() { // Disable continuous autofocus BEFORE super tries to set focus_absolute @@ -87,38 +76,4 @@ public void setAutoExposureImpl(boolean cameraAutoExposure) { setExposureRaw(this.lastExposureRaw); } } - - @Override - public void setExposureRaw(double exposureRaw) { - if (exposureRaw >= 0.0 && exposureAbsProp != null) { - int snapped = snapToDiscreteExposure((int) Math.round(exposureRaw)); - logger.debug( - "ThriftyOV9281: Setting " - + exposureAbsProp.getName() - + " to " - + snapped - + " (requested " - + exposureRaw - + ")"); - exposureAbsProp.set(snapped); - this.lastExposureRaw = snapped; - } - } - - /** - * Snap a requested exposure value to the nearest valid discrete value. The Sunplus SPCA2688 ISP - * only responds to specific exposure values; any other value produces a completely black frame. - */ - private static int snapToDiscreteExposure(int requested) { - int closest = DISCRETE_EXPOSURES[0]; - int minDist = Math.abs(requested - closest); - for (int discrete : DISCRETE_EXPOSURES) { - int dist = Math.abs(requested - discrete); - if (dist < minDist) { - minDist = dist; - closest = discrete; - } - } - return closest; - } } From 95b0d197ca4565b8b7ee6184e8f718b9bf6216bb Mon Sep 17 00:00:00 2001 From: Judson James Date: Sat, 13 Jun 2026 01:02:51 -0400 Subject: [PATCH 3/4] Add back `setUpExposureProperties` method declaration --- .../camera/USBCameras/ThriftyOV9281CameraSettables.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java index c9655b28c3..55a11201ce 100644 --- a/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java +++ b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java @@ -27,6 +27,11 @@ public class ThriftyOV9281CameraSettables extends GenericUSBCameraSettables { public ThriftyOV9281CameraSettables(CameraConfiguration configuration, UsbCamera camera) { super(configuration, camera); + } + + @Override + protected void setUpExposureProperties() { + super.setUpExposureProperties(); // Fix the exposure lower and upper limits // The minimum usable exposure is above the UI default of 20, so From 62a5719ee40b31902544ee7914804f4eedff43ea Mon Sep 17 00:00:00 2001 From: Judson James Date: Sat, 13 Jun 2026 01:08:06 -0400 Subject: [PATCH 4/4] Update ThriftiestCam to support 2027 conventions --- .../ThriftyOV9281CameraSettables.java | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java index 55a11201ce..236681c174 100644 --- a/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java +++ b/photon-core/src/main/java/org/photonvision/vision/camera/USBCameras/ThriftyOV9281CameraSettables.java @@ -17,23 +17,21 @@ package org.photonvision.vision.camera.USBCameras; -import java.util.HashMap; import org.photonvision.common.configuration.CameraConfiguration; import org.wpilib.util.PixelFormat; import org.wpilib.vision.camera.UsbCamera; -import org.wpilib.vision.camera.VideoMode; public class ThriftyOV9281CameraSettables extends GenericUSBCameraSettables { public ThriftyOV9281CameraSettables(CameraConfiguration configuration, UsbCamera camera) { super(configuration, camera); } - + @Override protected void setUpExposureProperties() { super.setUpExposureProperties(); - // Fix the exposure lower and upper limits + // Fix the exposure lower and upper limits. // The minimum usable exposure is above the UI default of 20, so // the user is expected to increase exposure until the camera can // pick up an image correctly. @@ -48,16 +46,10 @@ public void onCameraConnected() { // Filter out YUYV modes. The Sunplus SPCA2688 ISP's MJPEG encoder // breaks permanently if a YUYV↔MJPEG format switch occurs. // Only MJPEG modes (120fps) are usable; YUYV modes are 5-30fps anyway. - HashMap mjpegOnly = new HashMap<>(); - int newIdx = 0; - for (var entry : videoModes.entrySet()) { - if (entry.getValue().pixelFormat == PixelFormat.kMJPEG) { - mjpegOnly.put(newIdx++, entry.getValue()); - } - } - if (!mjpegOnly.isEmpty()) { - videoModes = mjpegOnly; - logger.info("Filtered to " + mjpegOnly.size() + " MJPEG-only modes (YUYV removed)"); + int originalSize = videoModes.size(); + videoModes.removeIf(m -> m.pixelFormat != PixelFormat.MJPEG); + if (videoModes.size() < originalSize) { + logger.info("Filtered to " + videoModes.size() + " MJPEG-only modes (YUYV removed)"); } }