3 cameras confirmed available
This commit is contained in:
parent
d12931641b
commit
35ddf9f844
@ -3,13 +3,16 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import gc
|
import gc
|
||||||
from flask import Flask, Response, render_template_string
|
import json
|
||||||
|
from flask import Flask, Response, render_template_string, jsonify
|
||||||
|
|
||||||
# --- PART 1: ADAPTIVE DETECTION ---
|
# --- CONFIGURATION ---
|
||||||
|
TARGET_NUM_CAMS = 3
|
||||||
|
DEFAULT_W = 1280
|
||||||
|
DEFAULT_H = 720
|
||||||
|
|
||||||
|
# --- PART 1: DETECTION ---
|
||||||
def scan_connected_cameras():
|
def scan_connected_cameras():
|
||||||
"""
|
|
||||||
Returns a list of serials ['400...', '400...'] and their config.
|
|
||||||
"""
|
|
||||||
print("--- Scanning for Basler Cameras ---")
|
print("--- Scanning for Basler Cameras ---")
|
||||||
detection_script = """
|
detection_script = """
|
||||||
import sys
|
import sys
|
||||||
@ -17,83 +20,57 @@ try:
|
|||||||
from pypylon import pylon
|
from pypylon import pylon
|
||||||
tl_factory = pylon.TlFactory.GetInstance()
|
tl_factory = pylon.TlFactory.GetInstance()
|
||||||
devices = tl_factory.EnumerateDevices()
|
devices = tl_factory.EnumerateDevices()
|
||||||
|
|
||||||
if not devices:
|
if not devices:
|
||||||
print("NONE")
|
print("NONE")
|
||||||
else:
|
else:
|
||||||
# Collect all serials
|
|
||||||
serials = [d.GetSerialNumber() for d in devices]
|
serials = [d.GetSerialNumber() for d in devices]
|
||||||
|
|
||||||
# Open the first one just to check capabilities/resolution
|
|
||||||
cam = pylon.InstantCamera(tl_factory.CreateDevice(devices[0]))
|
cam = pylon.InstantCamera(tl_factory.CreateDevice(devices[0]))
|
||||||
cam.Open()
|
cam.Open()
|
||||||
|
|
||||||
# Check Binning support
|
|
||||||
supported = 0
|
|
||||||
try:
|
try:
|
||||||
cam.BinningHorizontal.Value = 2
|
cam.BinningHorizontal.Value = 2
|
||||||
cam.BinningVertical.Value = 2
|
cam.BinningVertical.Value = 2
|
||||||
|
w = cam.Width.GetValue()
|
||||||
|
h = cam.Height.GetValue()
|
||||||
cam.BinningHorizontal.Value = 1
|
cam.BinningHorizontal.Value = 1
|
||||||
cam.BinningVertical.Value = 1
|
cam.BinningVertical.Value = 1
|
||||||
supported = 1
|
supported = 1
|
||||||
except:
|
except:
|
||||||
pass
|
w = cam.Width.GetValue()
|
||||||
|
h = cam.Height.GetValue()
|
||||||
w = cam.Width.GetValue()
|
supported = 0
|
||||||
h = cam.Height.GetValue()
|
|
||||||
cam.Close()
|
cam.Close()
|
||||||
|
|
||||||
# Output format: SERIAL1,SERIAL2|WIDTH|HEIGHT|BINNING_SUPPORTED
|
|
||||||
print(f"{','.join(serials)}|{w}|{h}|{supported}")
|
print(f"{','.join(serials)}|{w}|{h}|{supported}")
|
||||||
|
except Exception:
|
||||||
except Exception as e:
|
print("NONE")
|
||||||
print(f"ERROR:{e}")
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run([sys.executable, "-c", detection_script], capture_output=True, text=True)
|
result = subprocess.run([sys.executable, "-c", detection_script], capture_output=True, text=True)
|
||||||
output = result.stdout.strip()
|
output = result.stdout.strip()
|
||||||
|
if "NONE" in output or not output:
|
||||||
if "NONE" in output or "ERROR" in output or not output:
|
return [], DEFAULT_W, DEFAULT_H, False
|
||||||
print("No cameras detected!")
|
|
||||||
return [], 1920, 1080, False
|
|
||||||
|
|
||||||
# Parse output
|
|
||||||
parts = output.split('|')
|
parts = output.split('|')
|
||||||
serials_list = parts[0].split(',')
|
return parts[0].split(','), int(parts[1]), int(parts[2]), (parts[3] == '1')
|
||||||
w = int(parts[1])
|
except: return [], DEFAULT_W, DEFAULT_H, False
|
||||||
h = int(parts[2])
|
|
||||||
binning = (parts[3] == '1')
|
|
||||||
|
|
||||||
print(f"Found {len(serials_list)} cameras: {serials_list}")
|
|
||||||
return serials_list, w, h, binning
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Scanner failed: {e}")
|
|
||||||
return [], 1920, 1080, False
|
|
||||||
|
|
||||||
# Run Scan
|
|
||||||
DETECTED_SERIALS, CAM_W, CAM_H, BINNING_SUPPORTED = scan_connected_cameras()
|
DETECTED_SERIALS, CAM_W, CAM_H, BINNING_SUPPORTED = scan_connected_cameras()
|
||||||
NUM_CAMS = len(DETECTED_SERIALS)
|
ACTUAL_CAMS_COUNT = len(DETECTED_SERIALS)
|
||||||
|
|
||||||
# --- DYNAMIC RESOLUTION ---
|
# --- RESOLUTION & LAYOUT ---
|
||||||
INTERNAL_WIDTH = 1280
|
INTERNAL_WIDTH = 1280
|
||||||
scale = INTERNAL_WIDTH / CAM_W
|
if ACTUAL_CAMS_COUNT > 0:
|
||||||
INTERNAL_HEIGHT = int(CAM_H * scale)
|
scale = INTERNAL_WIDTH / CAM_W
|
||||||
|
INTERNAL_HEIGHT = int(CAM_H * scale)
|
||||||
|
else:
|
||||||
|
INTERNAL_HEIGHT = 720
|
||||||
if INTERNAL_HEIGHT % 2 != 0: INTERNAL_HEIGHT += 1
|
if INTERNAL_HEIGHT % 2 != 0: INTERNAL_HEIGHT += 1
|
||||||
|
|
||||||
# Web Tiling Logic
|
|
||||||
WEB_WIDTH = 1280
|
WEB_WIDTH = 1280
|
||||||
if NUM_CAMS > 0:
|
total_source_width = INTERNAL_WIDTH * TARGET_NUM_CAMS
|
||||||
# If 1 camera: Output is 1280x960
|
scale_tiled = WEB_WIDTH / total_source_width
|
||||||
# If 2 cameras: Output is 1280x(Height scaled for 2-wide)
|
WEB_HEIGHT = int(INTERNAL_HEIGHT * scale_tiled)
|
||||||
total_source_width = INTERNAL_WIDTH * NUM_CAMS
|
if WEB_HEIGHT % 2 != 0: WEB_HEIGHT += 1
|
||||||
scale_tiled = WEB_WIDTH / total_source_width
|
|
||||||
WEB_HEIGHT = int(INTERNAL_HEIGHT * scale_tiled)
|
|
||||||
if WEB_HEIGHT % 2 != 0: WEB_HEIGHT += 1
|
|
||||||
else:
|
|
||||||
WEB_HEIGHT = 720 # Fallback
|
|
||||||
|
|
||||||
print(f"ADAPTIVE MODE: Found {NUM_CAMS} Cams -> Layout {NUM_CAMS}x1 -> Web {WEB_WIDTH}x{WEB_HEIGHT}")
|
print(f"LAYOUT: {TARGET_NUM_CAMS} Slots | Detected: {ACTUAL_CAMS_COUNT} Cams")
|
||||||
|
|
||||||
# --- FLASK & GSTREAMER ---
|
# --- FLASK & GSTREAMER ---
|
||||||
import gi
|
import gi
|
||||||
@ -103,6 +80,9 @@ from gi.repository import Gst, GLib
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
frame_buffer = None
|
frame_buffer = None
|
||||||
buffer_lock = threading.Lock()
|
buffer_lock = threading.Lock()
|
||||||
|
current_fps = 0.0
|
||||||
|
frame_count = 0
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
class GStreamerPipeline(threading.Thread):
|
class GStreamerPipeline(threading.Thread):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@ -122,9 +102,17 @@ class GStreamerPipeline(threading.Thread):
|
|||||||
self.pipeline.set_state(Gst.State.NULL)
|
self.pipeline.set_state(Gst.State.NULL)
|
||||||
|
|
||||||
def on_new_sample(self, sink):
|
def on_new_sample(self, sink):
|
||||||
|
global frame_count, start_time, current_fps
|
||||||
sample = sink.emit("pull-sample")
|
sample = sink.emit("pull-sample")
|
||||||
if not sample: return Gst.FlowReturn.ERROR
|
if not sample: return Gst.FlowReturn.ERROR
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
# Calculate FPS every 30 frames
|
||||||
|
if frame_count % 30 == 0:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
current_fps = 30 / elapsed if elapsed > 0 else 0
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
buffer = sample.get_buffer()
|
buffer = sample.get_buffer()
|
||||||
success, map_info = buffer.map(Gst.MapFlags.READ)
|
success, map_info = buffer.map(Gst.MapFlags.READ)
|
||||||
if not success: return Gst.FlowReturn.ERROR
|
if not success: return Gst.FlowReturn.ERROR
|
||||||
@ -137,21 +125,9 @@ class GStreamerPipeline(threading.Thread):
|
|||||||
return Gst.FlowReturn.OK
|
return Gst.FlowReturn.OK
|
||||||
|
|
||||||
def build_pipeline(self):
|
def build_pipeline(self):
|
||||||
# Handle 0 Cameras gracefully (Placeholder)
|
# 1. CAMERA SETTINGS
|
||||||
if NUM_CAMS == 0:
|
# Note: We run cameras at 60 FPS for internal stability
|
||||||
print("Launching Placeholder Pipeline (No Cameras)...")
|
cam_settings = (
|
||||||
# Uses 'videotestsrc' to generate a test pattern so the web UI works
|
|
||||||
pipeline_str = (
|
|
||||||
f"videotestsrc pattern=smpte ! video/x-raw,width={WEB_WIDTH},height={WEB_HEIGHT},framerate=30/1 ! "
|
|
||||||
"jpegenc ! appsink name=sink emit-signals=True sync=False max-buffers=1 drop=True"
|
|
||||||
)
|
|
||||||
self.pipeline = Gst.parse_launch(pipeline_str)
|
|
||||||
appsink = self.pipeline.get_by_name("sink")
|
|
||||||
appsink.connect("new-sample", self.on_new_sample)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Settings
|
|
||||||
settings = (
|
|
||||||
"cam::TriggerMode=Off "
|
"cam::TriggerMode=Off "
|
||||||
"cam::AcquisitionFrameRateEnable=true cam::AcquisitionFrameRate=60.0 "
|
"cam::AcquisitionFrameRateEnable=true cam::AcquisitionFrameRate=60.0 "
|
||||||
"cam::ExposureAuto=Off "
|
"cam::ExposureAuto=Off "
|
||||||
@ -160,60 +136,110 @@ class GStreamerPipeline(threading.Thread):
|
|||||||
"cam::DeviceLinkThroughputLimitMode=Off "
|
"cam::DeviceLinkThroughputLimitMode=Off "
|
||||||
)
|
)
|
||||||
if BINNING_SUPPORTED:
|
if BINNING_SUPPORTED:
|
||||||
settings += "cam::BinningHorizontal=2 cam::BinningVertical=2 "
|
cam_settings += "cam::BinningHorizontal=2 cam::BinningVertical=2 "
|
||||||
|
|
||||||
# Pre-scaler (Crucial for stability)
|
|
||||||
pre_scale = (
|
|
||||||
"nvvideoconvert compute-hw=1 ! "
|
|
||||||
"video/x-raw(memory:NVMM), format=NV12, "
|
|
||||||
f"width={INTERNAL_WIDTH}, height={INTERNAL_HEIGHT} ! "
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. GENERATE SOURCES DYNAMICALLY
|
|
||||||
sources_str = ""
|
sources_str = ""
|
||||||
for i, serial in enumerate(DETECTED_SERIALS):
|
|
||||||
sources_str += (
|
|
||||||
f"pylonsrc device-serial-number={serial} {settings} ! "
|
|
||||||
"video/x-raw,format=GRAY8 ! "
|
|
||||||
"videoconvert ! "
|
|
||||||
"video/x-raw,format=I420 ! "
|
|
||||||
"nvvideoconvert compute-hw=1 ! "
|
|
||||||
"video/x-raw(memory:NVMM) ! "
|
|
||||||
f"{pre_scale}"
|
|
||||||
f"m.sink_{i} " # Link to the correct pad (0, 1, 2...)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. CONFIGURE MUXER & TILER
|
for i in range(TARGET_NUM_CAMS):
|
||||||
# Batch size MUST match number of cameras
|
if i < len(DETECTED_SERIALS):
|
||||||
|
# --- REAL CAMERA SOURCE ---
|
||||||
|
serial = DETECTED_SERIALS[i]
|
||||||
|
print(f"Slot {i}: Linking Camera {serial}")
|
||||||
|
|
||||||
|
pre_scale = (
|
||||||
|
"nvvideoconvert compute-hw=1 ! "
|
||||||
|
f"video/x-raw(memory:NVMM), format=NV12, width={INTERNAL_WIDTH}, height={INTERNAL_HEIGHT}, framerate=60/1 ! "
|
||||||
|
)
|
||||||
|
|
||||||
|
source = (
|
||||||
|
f"pylonsrc device-serial-number={serial} {cam_settings} ! "
|
||||||
|
"video/x-raw,format=GRAY8 ! "
|
||||||
|
"videoconvert ! "
|
||||||
|
"video/x-raw,format=I420 ! "
|
||||||
|
"nvvideoconvert compute-hw=1 ! "
|
||||||
|
"video/x-raw(memory:NVMM) ! "
|
||||||
|
f"{pre_scale}"
|
||||||
|
f"m.sink_{i} "
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# --- DISCONNECTED PLACEHOLDER ---
|
||||||
|
print(f"Slot {i}: Creating Placeholder (Synchronized)")
|
||||||
|
|
||||||
|
# FIX 1: Add 'videorate' to enforce strict timing on the fake source
|
||||||
|
# This prevents the placeholder from running too fast/slow and jittering the muxer
|
||||||
|
|
||||||
|
source = (
|
||||||
|
f"videotestsrc pattern=black is-live=true ! "
|
||||||
|
f"videorate ! " # <--- TIMING ENFORCER
|
||||||
|
f"video/x-raw,width={INTERNAL_WIDTH},height={INTERNAL_HEIGHT},format=I420,framerate=60/1 ! "
|
||||||
|
f"textoverlay text=\"DISCONNECTED\" valignment=center halignment=center font-desc=\"Sans, 48\" ! "
|
||||||
|
"nvvideoconvert compute-hw=1 ! "
|
||||||
|
f"video/x-raw(memory:NVMM),format=NV12,width={INTERNAL_WIDTH},height={INTERNAL_HEIGHT},framerate=60/1 ! "
|
||||||
|
f"m.sink_{i} "
|
||||||
|
)
|
||||||
|
|
||||||
|
sources_str += source
|
||||||
|
|
||||||
|
# 3. MUXER & PROCESSING
|
||||||
|
# FIX 2: batched-push-timeout=33000
|
||||||
|
# This tells the muxer: "If you have data, send it every 33ms (30fps). Don't wait forever."
|
||||||
|
|
||||||
|
# FIX 3: Output Videorate
|
||||||
|
# We process internally at 60fps (best for camera driver), but we DROP to 30fps
|
||||||
|
# for the web stream. This makes the network stream buttery smooth and consistent.
|
||||||
|
|
||||||
processing = (
|
processing = (
|
||||||
f"nvstreammux name=m batch-size={NUM_CAMS} width={INTERNAL_WIDTH} height={INTERNAL_HEIGHT} live-source=1 ! "
|
f"nvstreammux name=m batch-size={TARGET_NUM_CAMS} width={INTERNAL_WIDTH} height={INTERNAL_HEIGHT} "
|
||||||
f"nvmultistreamtiler width={WEB_WIDTH} height={WEB_HEIGHT} rows=1 columns={NUM_CAMS} ! "
|
f"live-source=1 batched-push-timeout=33000 ! " # <--- TIMEOUT FIX
|
||||||
|
f"nvmultistreamtiler width={WEB_WIDTH} height={WEB_HEIGHT} rows=1 columns={TARGET_NUM_CAMS} ! "
|
||||||
"nvvideoconvert compute-hw=1 ! "
|
"nvvideoconvert compute-hw=1 ! "
|
||||||
"video/x-raw(memory:NVMM) ! "
|
"video/x-raw(memory:NVMM) ! "
|
||||||
|
"videorate drop-only=true ! " # <--- DROPPING FRAMES CLEANLY
|
||||||
|
"video/x-raw(memory:NVMM), framerate=30/1 ! " # <--- Force 30 FPS Output
|
||||||
f"nvjpegenc quality=60 ! "
|
f"nvjpegenc quality=60 ! "
|
||||||
"appsink name=sink emit-signals=True sync=False max-buffers=1 drop=True"
|
"appsink name=sink emit-signals=True sync=False max-buffers=1 drop=True"
|
||||||
)
|
)
|
||||||
|
|
||||||
pipeline_str = f"{sources_str} {processing}"
|
pipeline_str = f"{sources_str} {processing}"
|
||||||
|
|
||||||
print(f"Launching ADAPTIVE Pipeline ({NUM_CAMS} Cameras)...")
|
print(f"Launching SMOOTH Pipeline...")
|
||||||
self.pipeline = Gst.parse_launch(pipeline_str)
|
self.pipeline = Gst.parse_launch(pipeline_str)
|
||||||
|
|
||||||
appsink = self.pipeline.get_by_name("sink")
|
appsink = self.pipeline.get_by_name("sink")
|
||||||
appsink.connect("new-sample", self.on_new_sample)
|
appsink.connect("new-sample", self.on_new_sample)
|
||||||
|
|
||||||
# --- Flask Routes ---
|
# --- FLASK ---
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template_string('''
|
return render_template_string('''
|
||||||
<html><body style="background:#111; color:white; text-align:center;">
|
<html>
|
||||||
<h1>Basler Feed ({{ num }} Cameras)</h1>
|
<head>
|
||||||
{% if num == 0 %}
|
<style>
|
||||||
<h2 style="color:red">NO CAMERAS DETECTED</h2>
|
body { background-color: #111; color: white; text-align: center; font-family: monospace; margin: 0; padding: 20px; }
|
||||||
{% endif %}
|
.container { position: relative; display: inline-block; border: 3px solid #4CAF50; }
|
||||||
<img src="{{ url_for('video_feed') }}" style="border: 2px solid #4CAF50; width:95%;">
|
img { display: block; max-width: 100%; height: auto; }
|
||||||
</body></html>
|
.hud {
|
||||||
''', num=NUM_CAMS)
|
position: absolute; top: 10px; left: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.6); color: #00FF00;
|
||||||
|
padding: 5px 10px; font-weight: bold; pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Basler 3-Cam (Smooth)</h1>
|
||||||
|
<div class="container">
|
||||||
|
<div class="hud" id="fps-counter">FPS: --</div>
|
||||||
|
<img src="{{ url_for('video_feed') }}">
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setInterval(function() {
|
||||||
|
fetch('/get_fps').then(r => r.json()).then(d => {
|
||||||
|
document.getElementById('fps-counter').innerText = "FPS: " + d.fps;
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
''')
|
||||||
|
|
||||||
@app.route('/video_feed')
|
@app.route('/video_feed')
|
||||||
def video_feed():
|
def video_feed():
|
||||||
@ -222,14 +248,17 @@ def video_feed():
|
|||||||
while True:
|
while True:
|
||||||
with buffer_lock:
|
with buffer_lock:
|
||||||
if frame_buffer:
|
if frame_buffer:
|
||||||
yield (b'--frame\r\n'
|
yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' + frame_buffer + b'\r\n')
|
||||||
b'Content-Type: image/jpeg\r\n\r\n' + frame_buffer + b'\r\n')
|
# Sleep 33ms (30 FPS)
|
||||||
time.sleep(0.016)
|
time.sleep(0.033)
|
||||||
count += 1
|
count += 1
|
||||||
if count % 200 == 0: gc.collect()
|
if count % 200 == 0: gc.collect()
|
||||||
|
|
||||||
return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
||||||
|
|
||||||
|
@app.route('/get_fps')
|
||||||
|
def get_fps():
|
||||||
|
return jsonify(fps=round(current_fps, 1))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
subprocess.run([sys.executable, "-c", "import gc; gc.collect()"])
|
subprocess.run([sys.executable, "-c", "import gc; gc.collect()"])
|
||||||
gst_thread = GStreamerPipeline()
|
gst_thread = GStreamerPipeline()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user