3 cameras confirmed available

This commit is contained in:
Tempest 2025-12-01 15:25:57 +07:00
parent d12931641b
commit 35ddf9f844

View File

@ -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() w = cam.Width.GetValue()
h = cam.Height.GetValue() h = cam.Height.GetValue()
supported = 0
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
if ACTUAL_CAMS_COUNT > 0:
scale = INTERNAL_WIDTH / CAM_W scale = INTERNAL_WIDTH / CAM_W
INTERNAL_HEIGHT = int(CAM_H * scale) 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
# If 2 cameras: Output is 1280x(Height scaled for 2-wide)
total_source_width = INTERNAL_WIDTH * NUM_CAMS
scale_tiled = WEB_WIDTH / total_source_width scale_tiled = WEB_WIDTH / total_source_width
WEB_HEIGHT = int(INTERNAL_HEIGHT * scale_tiled) WEB_HEIGHT = int(INTERNAL_HEIGHT * scale_tiled)
if WEB_HEIGHT % 2 != 0: WEB_HEIGHT += 1 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 "
sources_str = ""
for i in range(TARGET_NUM_CAMS):
if i < len(DETECTED_SERIALS):
# --- REAL CAMERA SOURCE ---
serial = DETECTED_SERIALS[i]
print(f"Slot {i}: Linking Camera {serial}")
# Pre-scaler (Crucial for stability)
pre_scale = ( pre_scale = (
"nvvideoconvert compute-hw=1 ! " "nvvideoconvert compute-hw=1 ! "
"video/x-raw(memory:NVMM), format=NV12, " f"video/x-raw(memory:NVMM), format=NV12, width={INTERNAL_WIDTH}, height={INTERNAL_HEIGHT}, framerate=60/1 ! "
f"width={INTERNAL_WIDTH}, height={INTERNAL_HEIGHT} ! "
) )
# 1. GENERATE SOURCES DYNAMICALLY source = (
sources_str = "" f"pylonsrc device-serial-number={serial} {cam_settings} ! "
for i, serial in enumerate(DETECTED_SERIALS):
sources_str += (
f"pylonsrc device-serial-number={serial} {settings} ! "
"video/x-raw,format=GRAY8 ! " "video/x-raw,format=GRAY8 ! "
"videoconvert ! " "videoconvert ! "
"video/x-raw,format=I420 ! " "video/x-raw,format=I420 ! "
"nvvideoconvert compute-hw=1 ! " "nvvideoconvert compute-hw=1 ! "
"video/x-raw(memory:NVMM) ! " "video/x-raw(memory:NVMM) ! "
f"{pre_scale}" f"{pre_scale}"
f"m.sink_{i} " # Link to the correct pad (0, 1, 2...) 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} "
) )
# 2. CONFIGURE MUXER & TILER sources_str += source
# Batch size MUST match number of cameras
# 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()