#!/usr/bin/env python3

####################################################################
# BigBrother  CCTV Recording & Live Viewing (mirroring) software   #
# Copyright 2016-2026 Andrew Wood                                  #
#                                                                  #
# bbeventmonitor uses an RT-DETRv2 model in ONNX format run with   #
# onnxruntime (Debian python3-onnxruntime package)                 #
# This version uses an ffmpeg subprocess to connect to cam         #
# and read frames and pipes them to OpenCV rather than having      #
# OpenCV connect to cam directly.                                  #
#                                                                  #
# Requires OpenCV2 v 4.10 or later (Debian python3-opencv package) #  
#                                                                  #
# www.bigbrothercctv.org                                           #
#                                                                  #
# Licensed under the GNU Public License v 3                        #
# The full license can be read at www.gnu.org/licenses/gpl-3.0.txt #
# and is included in the License.txt file included with this       #
# software.                                                        #
#                                                                  #
# BigBrother is free open source software but if you find it       #
# useful please consider making a donation to the Communications   #
# Museum Trust at www.communicationsmuseums.org.uk/donate          #
####################################################################


# Note about command line args: For time codes [*] can be passed to Python without escaping but for group name * needs to be escaped as \*
# this is because * is interpreted as a wildcard, but Python ignores the wildcard inside [], note that any calling shell script however
# WILL require [*] to be escaped as \[\*\] and * for group name needs to be double escaped as \\*
#Because this version uses ffmpeg externally it takes an additional arg giving path to the fffmpeg binary you want to use in line with the
#syntax for other BB sub programs which make use of ffmpeg

from datetime import datetime,timedelta
from concurrent.futures import ThreadPoolExecutor
import re,sys,os
import cv2
import numpy as np
import time
import threading
import signal
import calendar
import tempfile
import shutil
import subprocess
from queue import Queue, Empty
import getpass,pwd
import py7zr
import onnxruntime as ort
import io


####################################################################
#Stupid API change in Py7zr forces us to do it this way now
class MemIO:
    def __init__(self, filename):
        self.filename = filename
        self._buf = io.BytesIO()

    def write(self, s):
        return self._buf.write(s)

    def read(self, size=None):
        return self._buf.read() if size is None else self._buf.read(size)

    def seek(self, offset, whence=0):
        return self._buf.seek(offset, whence)

    def tell(self):
        return self._buf.tell()

    def flush(self):
        pass

    def close(self):
        pass  # keep the buffer alive so getvalue() works afterwards

    def size(self):
        return len(self._buf.getbuffer())

    def getvalue(self):
        return self._buf.getvalue()
#####################################################################

#####################################################################
#Stupid API change in Py7zr forces us to do it this way now
class MemFactory:
    def __init__(self):
        self.files = {}

    def create(self, filename):
        f = MemIO(filename)
        self.files[filename] = f
        return f
#####################################################################


##################################################################################################################################################################
class Detection:
	#A Detection represents a previously detected event, its bounding box x,y co-ordinates, time of detection and type code
	#It can be used to test if a 'new' detection is likeley to be the same object
    
    def __init__(self, typecode, startX, startY, endX, endY, timestamp):
        self.typecode=typecode
        self.startX = startX
        self.startY = startY
        self.endX= endX
        self.endY= endY
        self.timestamp= int(timestamp)
        self.firsttimestamp=self.timestamp
		
    def __str__(self):
        return "type: "+self.typecode+" startX: "+str(self.startX)+" startY: "+str(self.startY)+" endX: "+str(self.endX)+" endY: "+str(self.endY)+" ts: "+str(self.timestamp)+" ts1: "+str(self.firsttimestamp)+"("+self.timestampToHuman(self.firsttimestamp)+")"
	  
    def updateTimestamp(self):
        self.timestamp=int(time.time())
        
    def timestampToHuman(self,ts):
        return datetime.fromtimestamp(int(ts)).strftime('%Y-%m-%d %H:%M:%S')


    def isOlderThan(self,sec):
        if ( (int(time.time()) - self.timestamp) > sec   ):
            return True
        else:
            return False
			
    def intersects(self,startX,startY,endX,endY,code):
        #this func can be removed
        offset=50
        startoverlap=False
        endoverlap=False
        if (  (abs(startX-self.startX) < offset) and  (abs(startY-self.startY) < offset) ):
            startoverlap=True
		
        if (  (abs(endX-self.endX) < offset) and  (abs(endY-self.endY) < offset)  ):
            endoverlap=True
			
        if ((code==self.typecode) and (startoverlap) and (endoverlap)):
            return True
        else:
            return False
            
    def isSameType(self,code):
        if (code==self.typecode):
            return True
        else:
            return False

    def containedCompletelyWithin(self,otherstartX,otherendX,otherstartY,otherendY):
	    #if others co-ordinates are completely contained within this objects bounding box return True
        if (self.startX >=otherstartX) and (self.endX <=otherendX) and (self.startY >=otherstartY) and (self.endY <=otherendY):
            #self is 100% contained within other
            return True
        else:
            return False

    def calculateIoU(self,d2):
        #box1 is self
        #box2 is d2 (another Detection obj)
 
        a1=self.endX
        b1=self.endY
    
        a2=d2.endX
        b2=d2.endY
    
        area1 = (a1-self.startX)*(b1-self.startY) 
        area2 = (a2-d2.startX)*(b2-d2.startY)
 
        # find intersection box
        # find largest (x, y) coordinates 
        # for the start of the intersection bounding box and 
        # the smallest (x, y) coordinates for the 
        # end of the intersection bounding box
        xx = max(self.startX, d2.startX)
        yy = max(self.startY, d2.startY)
        aa = min(a1, a2)
        bb = min(b1, b2)
 
        # intersection bound box has coords (xx,yy) (aa,bb)
        # get its width and height
        w = max(0, aa - xx)
        h = max(0, bb - yy)
 
        intersectionarea = w*h
        unionarea = area1 + area2 - intersectionarea 
        IoU = intersectionarea / unionarea
    
        return IoU
        

        
    def centroidDistance(self, d2):
        cx1 = (self.startX + self.endX) / 2
        cy1 = (self.startY + self.endY) / 2
        cx2 = (d2.startX + d2.endX) / 2
        cy2 = (d2.startY + d2.endY) / 2
        return ((cx2 - cx1)**2 + (cy2 - cy1)**2) ** 0.5

			
#END OF CLASS Detection
##################################################################################################################################################################			

#############################################################################################################################################################################################################################	
def testNVIDIA(cameraurl, ffmpeg_command):
    cmd = [ffmpeg_command,"-hide_banner","-loglevel", "error","-nostdin","-hwaccel", "cuda","-hwaccel_output_format", "cuda","-timeout", "10000000","-i", cameraurl,"-frames:v", "1","-f", "image2","-vcodec","mjpeg","-"]


    try:
        result = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=15
        )

        return result.returncode == 0

    except subprocess.TimeoutExpired:
        return False
#############################################################################################################################################################################################################################	
    
    


#################################################################################################
def unescapeTimeString(inputstr):
    #inputstr is a timestr in format "\[20:00-21:00\]" or "\[20:00-21:00\]/\[22:00-22:30\]" or "\[\*\]"
    #remove \ escape chars and return string
    outputstr=""
    for char in inputstr:
        if char!="\\":
            outputstr=outputstr+char
    return outputstr
##################################################################################################

##############################################
def splitStartEndTime(txt):
	components1 = txt.split("-")
	start=components1[0].replace("[", "")
	end=components1[1].replace("]", "")
	components2=[start,end]
	return components2
##############################################

############################################## 
def splitHoursMinutes(txt):
	components = txt.split(":")
	return components
##############################################


##############################################
def isHour(txt):
	hr=int(txt)
	if hr>=0 and hr <=23:
		return True
	else:
		return False;
##############################################        
        
##############################################       
def isMinute(txt):
	minutes=int(txt)
	if minutes>=0 and minutes <=59:
		return True
	else:
		return False;
############################################## 

 
##############################################      
def startEndSane(starthr,startmin,endhr,endmin):
	starthr=int(starthr)
	startmin=int(startmin)
	endhr=int(endhr)
	endmin=int(endmin)
	
	
	if ((starthr==endhr) and (startmin>=endmin)):
		return False
	return True;
##############################################


##############################################
def timeAlreadyPassed(hr,minutes):
	now = datetime.now()
	todaytarget = now.replace(hour=int(hr), minute=int(minutes), second=0, microsecond=0)
	if now < todaytarget:
		return False
	else:
		return True
##############################################

      
##############################################
# Convert a '[HH:MM-HH:MM]' string into (start, end) datetime objects
def parseRange(r):
	r = r.strip('[]')
	start_str, end_str = r.split('-')
	base_date = datetime(2000, 1, 1)  # arbitrary date
	start = datetime.strptime(start_str, "%H:%M").replace(year=2000, month=1, day=1)
	end = datetime.strptime(end_str, "%H:%M").replace(year=2000, month=1, day=1)

	# If end time is earlier than start, it's an overnight range
	if end <= start:
		end += timedelta(days=1)

	return start, end
##############################################    
    
##############################################
# Check for overlaps
def checkOverlap(ranges):
	base_date = datetime(2000, 1, 1)
	intervals = []

	for r in ranges:
		r = r.strip("[]")
		start_str, end_str = r.split("-")
		start = datetime.strptime(start_str, "%H:%M").replace(year=2000, month=1, day=1)
		end = datetime.strptime(end_str, "%H:%M").replace(year=2000, month=1, day=1)
		if end <= start:
			end += timedelta(days=1)
		intervals.append((start, end))
		intervals.append((start + timedelta(days=1), end + timedelta(days=1)))

	for i in range(len(intervals)):
		for j in range(i + 1, len(intervals)):
			s1, e1 = intervals[i]
			s2, e2 = intervals[j]
			if s1 < e2 and s2 < e1:
				return True
	return False
##############################################

##############################################    
def ignoreEvent(times):
    now = datetime.now()
    current_minutes = now.hour * 60 + now.minute
    for txt in times:
        startendtime = splitStartEndTime(txt)
        start = splitHoursMinutes(startendtime[0])
        end = splitHoursMinutes(startendtime[1])
        start_minutes = int(start[0]) * 60 + int(start[1])
        end_minutes = int(end[0]) * 60 + int(end[1])
        print("Checking if we are within: ", txt)

        # Normal range, for example 20:00-21:00
        if start_minutes < end_minutes:

            if start_minutes <= current_minutes < end_minutes:
                return True

        # Overnight range, for example 22:00-02:00
        elif start_minutes > end_minutes:

            if current_minutes >= start_minutes or current_minutes < end_minutes:
                return True

        # start == end
        else:
            return False

    return False

##############################################



################################################
def shouldProcess(eventcode):

    try:
        eventidx=eventcodes.index(eventcode)
    except ValueError as e:
        return False;
    
    if (eventignoretimes[eventidx]!="[*]"):
        if (ignoreEvent(eventignoretimes[eventidx])):
            #"Ignore
            return False
        else:
            #there is a time restriction for this event but we are not in it currently
            return True;
    else:
        #there is no time restriction for this event
       return True
    
################################################

#######################################
def fileIsExe(filepath):
    path = shutil.which(filepath)
    if path is None:
        return False
    else:
        return True
#######################################

#######################################
def fileIsReadable(filepath):
    try:
        file=open(filepath,'r')
        file.close()
        return True
    except IOError:
        return False
#######################################


##############################################
def onlyContainsLettersOrNumbers(str):
	result=re.search("^[A-Za-z0-9]+$",str)
	if (result==None):
		return False
	else:
		return True
##############################################

##############################################
def checkEventDirectory(path,groupname,eventlogtype):
    #folder must exist
    if (os.path.isdir(path)==False):
        return False
    print("Checking Event logging directory, GroupName:"+groupname+" EventLogType:"+eventlogtype)
    if ((groupname!="*") and (eventlogtype=="C")):
        print("GroupName is set and logtype is C, checking there is a group directory...")
        if (os.path.isdir(path+"/bycamera/"+groupname)==False):
            return False
        print("group directory OK")

    if ( (os.path.isdir(path+"/byday/Monday")==False) or (os.path.isdir(path+"/byday/Tuesday")==False) or (os.path.isdir(path+"/byday/Thursday")==False) or (os.path.isdir(path+"/byday/Wednesday")==False) or (os.path.isdir(path+"/byday/Friday")==False) or (os.path.isdir(path+"/byday/Saturday")==False) or (os.path.isdir(path+"/byday/Sunday")==False)  ):
        return False

    if (directoryIsWritable(path+"/byday/Monday")==False):
        return False

    if (directoryIsWritable(path+"/byday/Tuesday")==False):
        return False

    if (directoryIsWritable(path+"/byday/Wednesday")==False):
        return False
		
    if (directoryIsWritable(path+"/byday/Thursday")==False):
        return False

    if (directoryIsWritable(path+"/byday/Friday")==False):
        return False

    if (directoryIsWritable(path+"/byday/Saturday")==False):
        return False
    
    return True



##############################################

###############################################
def directoryIsWritable(path):
    try:
        testfile = tempfile.TemporaryFile(dir = path)
        testfile.close()
    except OSError as e:
        return False
    return True
###############################################

#############################################
def Log(msg,throw=False):
       	try:
                now=datetime.now()
                datestr=now.strftime("%Y-%m-%d %H:%M")
                file=open(logfilepath,'a')
                pid=str(os.getpid())

                #if given just \n\n output it with no datetime prepended
                if msg=="\n\n":
                        file.write(msg)
                        file.close()
                        return()

                if msg[len(msg)-1]!="\n":
                        msg=msg+"\n"
                file.write(datestr+" "+"[bbeventmonitor] ["+cameraname+"] "+"[PID "+pid+"] "+msg)
                file.close()
        except IOError as e:
                if (throw):
                        raise e
                else:
                        print ("ERROR: Could not open Log file an IOError was thrown, ignoring")
###############################################

#############################################
def LogEvent(code, cameraname,frame,throw=False):
    now=datetime.now().astimezone()
    unixtimestampgmt = int(now.timestamp())
    datestr=now.strftime("%Y-%m-%d %H:%M:%S")
    mydir=os.path.abspath(os.path.dirname(__file__)) #gives dir without trailing /    
         
    try:
        datestrforframe=now.strftime("%Y-%m-%d--%H%M%S") #avoid using colons as it conflicts with Windows permitted file names
        dayname=calendar.day_name[now.weekday()]
        if (eventlogtype=="D"):
            framedirectory=eventlogpath+"/"+dayname
        else:
            framedirectory=eventlogpath
        #Save the image to the snapshot log folder, check if filename already exists (it might if clocks just went back for DST), if so add int to it
        n=0
        filepath=framedirectory+"/"+cameraname+"--"+datestrforframe+"--"+code+'.jpg'
        while fileIsReadable(filepath):
            n=n+1
            filepath=framedirectory+"/"+cameraname+"--"+datestrforframe+"--"+code+'-'+str(n)+'.jpg'
        frameresult=cv2.imwrite(filepath, frame)
        #Save the image to mirrorwebroot/snapshots
        n=0
        firstfilepath=filepath
        filepath=mydir+"/mirrorwebroot/snapshots/"+cameraname+"--"+datestrforframe+"--"+code+'.jpg'
        while fileIsReadable(filepath):
            n=n+1
            filepath=mydir+"/mirrorwebroot/snapshots/"+cameraname+"--"+datestrforframe+"--"+code+'-'+str(n)+'.jpg'
        
        #shutil.copy2(firstfilepath, filepath) 

        subprocess.Popen('cp '+firstfilepath+" "+filepath, shell=True)
 
    except:
        print ("ERROR: Could not write frame to Event Log directory or mirrorwebroot/snapshots, an exception was thrown")

    if frameresult==False:
        print ("ERROR: Could not write frame to Event Log directory, imwrite returned False")
   
   
    #log to live notify log
    liveeventlogpath=mydir+"/mirrorwebroot/org.bigbrothercctv.bigbrother.aieventlog.txt"
    try:
        file=open(liveeventlogpath,'a')
        components=filepath.split("/")
        filepath=components[len(components)-1] # get just filename
        file.write(datestr+" "+cameraname+" "+code+" "+filepath+" "+str(unixtimestampgmt)+"\n")
        file.close()
    except IOError as e:
        if (throw):
            raise e
        else:
             print ("ERROR: Could not open Live Event Log file "+liveeventlogpath+" an IOError was thrown, ignoring")

###############################################


##################################################################################
def LogIfOfInterest(id,frame,confidence,startX,startY,endX,endY,tid):
    #must be called while holding eventLogLock
    print("LogIfOfInterest [thread: "+str(tid)+"] checking "+id+" confidence:"+str(confidence)+" X1:"+str(startX)+" Y1:"+str(startY)+" X2:"+str(endX)+" Y2:"+str(endY))
    
    classPids=["person"] #ids  which relate to persons
    classVids=["bicycle","van","bus", "car","motorcycle","truck"]  #ids which relate to vehicles

    if id in classPids:
        print("Found Person [thread: "+str(tid)+"]")
        if shouldProcess("P"):
            print("Not currently in an ignore timeframe [thread: "+str(tid)+"]")
            if (isRepeatDetection("P",startX,startY,endX,endY)):
                print("repeat detection, ignoring")
            else:
                if confidence>=confidenceUpperThreshold:
                    LogEvent("P", cameraname,frame)
                    d=Detection("P",startX,startY,endX,endY,int(time.time()))
                    detections.append(d)
        else:
            print("Ignoring, within an ignore timeframe [thread: "+str(tid)+"]")
        
    if id in classVids:
        
        print("Found Vehicle")
        if shouldProcess("V"):
            print("Not currently in an ignore timeframe [thread: "+str(tid)+"]")
            if (isRepeatDetection("V",startX,startY,endX,endY)):
                print("repeat detection, ignoring")
            else:
                if confidence>=confidenceUpperThreshold:
                    LogEvent("V", cameraname,frame)
                    d=Detection("V",startX,startY,endX,endY,int(time.time()))
                    detections.append(d)
        else:
            print("Ignoring, within an ignore timeframe [thread: "+str(tid)+"]")
###############################################################################



###############################################################################
def isRepeatDetection(typecode,startX,startY,endX,endY,):
    #must be called while holding eventLogLock
    prospect=Detection(typecode,startX,startY,endX,endY,int(time.time()))
    for detection in detections:
        if ( detection.isSameType(typecode) and (detection.calculateIoU(prospect)>iouThreshold)  ):
            detection.updateTimestamp()
            return True
        if ( detection.isSameType(typecode) and (detection.containedCompletelyWithin(startX,endX,startY,endY)  ) ):
            detection.updateTimestamp()
            return True
    return False
##############################################################################



###############################################################################
def processFrame(threadidx,frame):
    tsstart=time.time()
    locks[threadidx].acquire()
    
    # Plain resize to 640x640, RGB, scaled 0-1 (no letterbox, no mean/std)
    blob = cv2.dnn.blobFromImage(frame, 1/255.0, (640, 640), swapRB=True,crop=False)
    sess=models[threadidx]

    width = frame.shape[1] 
    height = frame.shape[0]

    det_labels, det_boxes, det_scores = runModel(sess, blob, width, height)

    # RT-DETR needs no NMS - just discard detections below the lower threshold
    keep = det_scores >= confidenceLowerThreshold

    for (x1, y1, x2, y2), class_id, confidence in zip(det_boxes[keep], det_labels[keep], det_scores[keep]):
        class_id = int(class_id)
        confidence = float(confidence)
        if class_id not in classNames:
            continue

        left = int(max(0, min(width - 1, x1)))
        top = int(max(0, min(height - 1, y1)))
        right = int(max(0, min(width - 1, x2)))
        bottom = int(max(0, min(height - 1, y2)))
        bwidth = right - left
        bheight = bottom - top
        

        if markup:
            #Draw text onto image at location xy 
            cv2.rectangle(frame, (left, top), (left + bwidth, top + bheight), BLUE, 3*THICKNESS)
            label = "{}:{:.2f}".format(classNames[class_id], confidence)
            label=label+" "+" ps:"+starttimestr
            text_size = cv2.getTextSize(label, FONT_FACE, FONT_SCALE, THICKNESS)
            dim, baseline = text_size[0], text_size[1]
            # Use text size to create a rectangle.
            cv2.rectangle(frame, (left,top), (left + dim[0], top + dim[1] + baseline), BLUE, cv2.FILLED);
            # Display text inside the rectangle.
            cv2.putText(frame, label, (left, top + dim[1]), FONT_FACE, FONT_SCALE, WHITE, THICKNESS, cv2.LINE_AA)
    
        x_top_left=left
        y_top_left=top
        x_bottom_right=(left+bwidth)-1
        y_bottom_right=(top+bheight)-1
        
        eventLogLock.acquire()
        LogIfOfInterest(classNames[class_id],frame,confidence,x_top_left,y_top_left,x_bottom_right,y_bottom_right,threadidx)
        eventLogLock.release()
    
    tsend=time.time()
    print("Thread "+str(threadidx)+" (thread sys id "+str(threading.get_ident())+") time taken: "+str(tsend-tsstart)+" sec")
    locks[threadidx].release()

##############################################################################

##############################################################################
def housekeeping():
    #thread routine executed by housekeeping thread only
    global starttimestr
    while (exit_event.is_set()==False):
        time.sleep(10)
        eventLogLock.acquire()
        beforesize=(len(detections))
        #remove any which have not had timestamp updated for maxAge secs
        detections[:] = [x for x in detections if not x.isOlderThan(maxAge)]
        aftersize=len(detections)
        diff=beforesize-aftersize
        diff=str(diff)
        print("========================================================================================================")
        print(" ")
        print(" ")
        print("Program started:"+starttimestr)
        print("housekeeping removed "+diff+" old detections. "+str(aftersize)+" active detections")
        for detection in detections:
        	print(detection)
        print(" ")
        print(" ")
        print("========================================================================================================")
        eventLogLock.release()
##############################################################################

###############################################################################
def signalHandler(signum, frame):
    print('Signal handler called with signal',signum)
    exit_event.set()

###############################################################################


###############################################################################
def processFrameWrapper(threadidx, frame):
    try:
        processFrame(threadidx, frame)
    finally:
        sema.release()
###############################################################################

###############################################################################
def runModel(sess, blob, width, height):
    #Run RT-DETR model on a 640x640 blob, return (labels, boxes xyxy in frame pixels, scores)
    #Supported export formats:
    # - Official RT-DETRv2 export: inputs "images" + "orig_target_sizes" (int64 [w,h]),
    #   outputs "labels", "boxes" (xyxy in original image pixels), "scores"
    # - Raw export: outputs "pred_logits" + "pred_boxes" (cx,cy,w,h normalised 0-1)
    # - Single output: (1, num_queries, 4 + num_classes), boxes cx,cy,w,h
    if OFFICIAL_EXPORT:
        feeds = {}
        for name in MODEL_IN_NAMES:
            if "size" in name:
                feeds[name] = np.array([[width, height]], dtype=np.int64)
            else:
                feeds[name] = blob
        outs = dict(zip(MODEL_OUT_NAMES, sess.run(MODEL_OUT_NAMES, feeds)))
        labels = outs["labels"].reshape(-1).astype(int)
        boxes = outs["boxes"].reshape(-1, 4).astype(np.float32)
        scores = outs["scores"].reshape(-1).astype(np.float32)
        return labels, boxes, scores

    if RAW_EXPORT:
        a, b = sess.run(MODEL_OUT_NAMES, {MODEL_IN_NAMES[0]: blob})
        if a.shape[-1] == 4:
            raw_boxes, logits = a, b
        else:
            logits, raw_boxes = a, b
        raw_boxes = raw_boxes.reshape(-1, 4)
        probs = 1.0 / (1.0 + np.exp(-logits.reshape(raw_boxes.shape[0], -1)))   # sigmoid
        sx, sy = width, height
    else:
        pred = sess.run(None, {MODEL_IN_NAMES[0]: blob})[0]
        pred = pred.reshape(-1, pred.shape[-1])
        raw_boxes, probs = pred[:, :4], pred[:, 4:]
        if raw_boxes.max() > 2.0:
            sx, sy = width / 640, height / 640
        else:
            sx, sy = width, height

    labels = probs.argmax(axis=1).astype(int)
    scores = probs.max(axis=1).astype(np.float32)
    cx, cy, w, h = raw_boxes[:, 0], raw_boxes[:, 1], raw_boxes[:, 2], raw_boxes[:, 3]
    boxes = np.stack([(cx - w/2) * sx, (cy - h/2) * sy, (cx + w/2) * sx, (cy + h/2) * sy], axis=1)
    return labels, boxes, scores
###############################################################################

###############################################################################
def setModelFormat(sess):
    global MODEL_IN_NAMES, MODEL_OUT_NAMES, OFFICIAL_EXPORT, RAW_EXPORT
    MODEL_IN_NAMES = [i.name for i in sess.get_inputs()]
    MODEL_OUT_NAMES = [o.name for o in sess.get_outputs()]
    OFFICIAL_EXPORT = {"labels", "boxes", "scores"}.issubset(set(MODEL_OUT_NAMES))
    RAW_EXPORT = (not OFFICIAL_EXPORT) and len(MODEL_OUT_NAMES) == 2
###############################################################################

###############################################################################
def loadModel(model_bytes):
    #try each available execution provider in order of preference, use the first that
    #loads the model and passes a test run
    available = ort.get_available_providers()
    candidates = []
    if "OpenVINOExecutionProvider" in available:
        candidates.append([("OpenVINOExecutionProvider", {"device_type": "NPU"})])
    if "CoreMLExecutionProvider" in available:
        candidates.append(["CoreMLExecutionProvider"])
    if "VitisAIExecutionProvider" in available:
        candidates.append(["VitisAIExecutionProvider"])
    if "CUDAExecutionProvider" in available:
        candidates.append(["CUDAExecutionProvider"])
    if "ROCMExecutionProvider" in available:
        candidates.append(["ROCMExecutionProvider"])
    if "MIGraphXExecutionProvider" in available:
        candidates.append(["MIGraphXExecutionProvider"])
    if "ACLExecutionProvider" in available:
        candidates.append(["ACLExecutionProvider"])
    candidates.append(["CPUExecutionProvider"])

    for providers in candidates:
        try:
            opts = ort.SessionOptions()
            opts.inter_op_num_threads = 1
            opts.add_session_config_entry("session.intra_op.allow_spinning", "0")
            if providers == ["CPUExecutionProvider","ACLExecutionProvider"]:
                opts.intra_op_num_threads = ORT_THREADS
            else:
                opts.intra_op_num_threads = 1 #accelerator does the heavy work, CPU only handles any fallback ops

            print("Trying NN exe provider:", providers)
            sess = ort.InferenceSession(model_bytes, sess_options=opts, providers=providers)
            setModelFormat(sess)
            runModel(sess, np.zeros((1, 3, 640, 640), dtype=np.float32), 640, 640)
            print("Using NN exe provider:", sess.get_providers())
            Log("Using NN exe provider: "+str(sess.get_providers()))
            print("Model inputs: "+str(MODEL_IN_NAMES)+" outputs: "+str(MODEL_OUT_NAMES))
            Log("Model inputs: "+str(MODEL_IN_NAMES)+" outputs: "+str(MODEL_OUT_NAMES))
            return sess
        except Exception as e:
            print("NN exe provider failed:", providers, str(e))
            Log("NN exe provider failed: "+str(providers)+" "+str(e))

    print("FATAL: Could not run the model with any execution provider")
    Log("FATAL: Could not run the model with any execution provider")
    exit(1)
###############################################################################

###############################################################################
def watchdog():
    while not exit_event.is_set():
        if time.time() - last_frame_time > 15:
            print("FATAL: Camera timeout, exiting")
            exit_event.set()
            os._exit(1)     # force process exit

        time.sleep(1)
###############################################################################

###############################################################################
def reader():
    global last_frame_time
    global FRAMESIZEBYTES
    global FRAMEW
    global FRAMEH

    if nvidia:
        #use GPU
        Log("Using NVIDIA GPU for ffmpeg")
        print("Using NVIDIA GPU for ffmpeg")
        cmd = [ffmpeg_command,"-hide_banner","-loglevel", "error","-nostdin","-hwaccel", "cuda","-hwaccel_output_format", "cuda","-timeout", "10000000","-i", cameraurl,"-vf",f"fps=4,scale_cuda={FRAMEW}:{FRAMEH},hwdownload,format=bgr24","-pix_fmt", "bgr24","-f", "rawvideo","-"]
    else:
        #use CPU
        Log("Using CPU for ffmpeg")
        print("Using CPU for ffmpeg")
        cmd = [ffmpeg_command,"-hide_banner","-loglevel", "error","-nostdin","-timeout","10000000","-i", cameraurl,"-vf", "fps=5,scale="+str(FRAMEW)+":"+str(FRAMEH),"-pix_fmt", "bgr24","-f", "rawvideo","-"]

    process = None
    try:
        print("Starting ffmpeg subprocess")
        Log("Starting ffmpeg subprocess")

        process = subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=None,bufsize=1024 * 1024)
        print("subprocess ffmpeg PID:", process.pid)
        Log("subprocess ffmpeg PID:"+str(process.pid))

        buffer = b""

        while not exit_event.is_set():
            tread = time.time() 
            needed = FRAMESIZEBYTES - len(buffer)
            data = process.stdout.read(needed)
            read_time = time.time() - tread 

            if read_time > 1:
                print("WARNING: FFmpeg pipe read took", read_time, "seconds") 

            if not data:
                print("ffmpeg return code:", process.poll())
                print("ffmpeg returned no data")
                Log("ffmpeg returned no data")
                break

            buffer += data

            if len(buffer) < FRAMESIZEBYTES:
                continue

            tdecode = time.time() 
            frame = np.frombuffer(buffer, dtype=np.uint8).reshape((FRAMEH, FRAMEW, 3))
            decode_time = time.time() - tdecode 
            if decode_time > 0.5:
                print("WARNING: frame reshape took", decode_time, "seconds") 

            last_frame_time = time.time()

            try:
                frame_queue.put(frame.copy(), timeout=0)
            except:
                print("WARNING: queue full, frame dropped and will not be processed for events. This may cause events to be missed. Try increasing CPU capacity\n")
                Log("WARNING: queue full, frame dropped and will not be processed for events. This may cause events to be missed. Try increasing CPU capacity\n")

            buffer = b""

    except Exception as e:
        print("ffmpeg reader error: " + str(e))
        Log("ffmpeg reader error: " + str(e))
    finally:
        if process is not None:
            try:
                process.kill()
            except:
                pass
            try:
                process.wait(timeout=2)
            except:
                pass
        print("ffmpeg reader stopped")
        Log("ffmpeg reader stopped")
########################################################################################################

#######################################
def fileIsExe(filepath):
    path = shutil.which(filepath)
    if path is None:
        return False
    else:
        return True
  

#######################################




displayargcount=str((len(sys.argv)-1))
if (len(sys.argv) < 10):
    print ("Usage: "+sys.argv[0]+" /path/to/ffmpeg /path/to/generallog /path/to/eventlog EVENTLOGBYTYPE CameraName GroupName CameraURL EventCodeA EventIgnoreTimesA ... EventCodeN EventIgnoreTimesN")
    print("Example: "+sys.argv[0]+" /path /path /path D CamX GroupX rtsp://x.x.x.x P [HH:MM-HH:MM] V [HH:MM-HH:MM]/[HH:MM-HH:MM]")
    print("\n")
    print("[*] can be specified in place of EventIgnoreTimes to monitor at all times")
    print("Example: "+sys.argv[0]+" /path /path /path D CamX GroupX rtsp://x.x.x.x [P] *")
    print("\n")
    print("You only specified "+displayargcount+" arguments")
    exit(1)
    
if (  (len(sys.argv) > 10) and (len(sys.argv) % 2 != 0) ):
	#If > 10 args specified args 11+ are extra events and ignore times and must be paired, so check we have even num of args
    print ("Usage: "+sys.argv[0]+" /path/to/ffmpeg /path/to/generallog /path/to/eventlog EVENTLOGBYTYPE CameraName GroupName CameraURL EventCodeA EventIgnoreTimesA ... EventCodeN EventIgnoreTimesN")
    print("Example: "+sys.argv[0]+" /path /path /path D CamX GroupX rtsp://x.x.x.x P [HH:MM-HH:MM] V [HH:MM-HH:MM]/[HH:MM-HH:MM]")
    print("\n")
    print("[*] can be specified in place of EventIgnoreTimes to monitor at all times")
    print("Example: "+sys.argv[0]+" /path /path /path D CamX Group X rtsp://x.x.x.x P [*]")
    print("\n")
    print("You only specified "+displayargcount+" arguments, EventCodes and EventIgnoreTimes must be paired")
    exit(1)    

exit_event = threading.Event()

ffmpeg_command=sys.argv[1]
logfilepath=sys.argv[2]
eventlogpath=sys.argv[3]
eventlogtype=sys.argv[4]
cameraname=sys.argv[5]
groupname=sys.argv[6]
cameraurl=sys.argv[7] #this will be validated by checking if ffmpeg successfully connects to it

groupname=groupname.strip("\\")




if fileIsExe(ffmpeg_command):
    print ("ffmpegcommand appears to be executable");
else:
    print ("Error "+ffmpeg_command+" is not executable. Check path is correct and bigbrother user has execute permission")
    exit(1)

#these 3 arrays contain entry for each event correlated by index
eventcodes=[]
eventignorestr=[] #used during validation only
eventignoretimes=[] #to be used after validation is complete

####################################################################
#Alter these 2 values to adjust accuracy of detection
confidenceUpperThreshold=0.75 #if this is new detection, it must meet or exceed this threshold
confidenceLowerThreshold=0.4 #discard all detections less than this
iouThreshold=0.65 #overlap threshold when detecting duplicates
maxAge=25 #seconds since Detection obj last updated before it 
ORT_THREADS=1 #CPU threads onnxruntime uses per model for inference, increase if frames are processed too slowly
####################################################################
markup=False #useful for debugging, markup logged frame with detection
BLUE   = (255,178,50)
BLACK   = (0,0,0)
WHITE   = (255,255,255)
FONT_FACE = cv2.FONT_HERSHEY_SIMPLEX
FONT_SCALE = 0.7
THICKNESS = 1
####################################################################


last_frame_time = time.time()



#lock for access to LogIfOfInterest func & detections array
eventLogLock=threading.Lock()

#will store previously detected objects/events (protected by eventLogLock)
detections=[]

print("Got "+displayargcount+" arguments")

mydir=os.path.abspath(os.path.dirname(__file__)) #gives dir without trailing /

code=True
for x in range(8,len(sys.argv)):
    if (code):
        eventcodes.append(sys.argv[x])
        code=False
        print("Got EventCode:"+sys.argv[x])
    else:
        eventignorestr.append(unescapeTimeString(sys.argv[x]))
        print("Got EventIgnoreTimes:"+unescapeTimeString(sys.argv[x]))
        code=True

#validate CameraName before we start logging so its safe to put CameraName into log
if onlyContainsLettersOrNumbers(cameraname)==False:
    print("ERROR: CameraName is invalid, must only contain letters or numbers")
    exit(1)


try:
	Log("bbeventmonitor starting for camera "+cameraname+"...",True)
except IOError:
	print("ERROR: Could not open log file "+logfilepath+", check the file permissions")
	exit(1)

#eventlogpath must not have trailing / so remove it if present
if eventlogpath[len(eventlogpath)-1]=="/":
    eventlogpath = eventlogpath[:-1]

if groupname!="*":
    if onlyContainsLettersOrNumbers(groupname)==False:
        print("ERROR: GroupName is invalid, must only contain letters or numbers")
        exit(1)



if checkEventDirectory(eventlogpath,groupname,eventlogtype)==False:
    print("ERROR: EventLog is not writable, check it contains a /byday directory containing a directory for each day (named /Monday, /Tuesday etc) and a /bycamera directory with a subdirectory for each group")
    Log("ERROR: EventLog is not writable, check it contains a /byday directory containing a directory for each day (named /Monday, /Tuesday etc) and a /bycamera directory with a subdirectory for each group")
    exit(1)

if eventlogtype=="C":
    if groupname=="*":
        print("ERROR: EventLogBy is C (by camera) but GroupName is not set. GroupName cannot be * if logging by camera")
        Log("ERROR: EventLogBy is C (by camera) but GroupName is not set. GroupName cannot be * if logging by camera")
        exit(1)
    eventlogpath=eventlogpath+"/bycamera/"+groupname
elif eventlogtype=="D":
    eventlogpath=eventlogpath+"/byday"
else:
    print("ERROR: EventLogBy is not valid must be D (by day) or C (by camera)")
    Log("ERROR: EventLogBy is not valid must be D (by day) or C (by camera)")
    exit(1)



try:
    elog = open(mydir+"/mirrorwebroot/org.bigbrothercctv.bigbrother.aieventlog.txt", "a")

    if (elog.writable()==False):
        print("ERROR: mirrorwebroot/org.bigbrothercctv.bigbrother.aieventlog.txt is not writable")
        Log("ERROR: mirrorwebroot/org.bigbrothercctv.bigbrother.aieventlog.txt is not writable")
        exit(1)
    elog.close()
except IOError as e:
    print ("ERROR: Could not open mirrorwebroot/org.bigbrothercctv.bigbrother.aieventlog.txt an IOError was thrown")
    exit(1)



if directoryIsWritable(mydir+"/mirrorwebroot/snapshots"):
    print("mirrorwebroot/snapshots is writable OK")
else:
    Log("ERROR: mirrorwebroot/snapshots is not writable")
    print("ERROR: mirrorwebroot/snapshots is not writable")
    exit(1)


if len(eventcodes)!=len(eventignorestr):
    Log("ERROR: Number of EventCodes and EventIgnoreTimes values do not match, if an event is to be monitored at all times specify * for EventIgnoreTimes")
    print("ERROR: Number of EventCodes and EventIgnoreTimes values do not match, if an event is to be monitored at all times specify * for EventIgnoreTimes")	
    exit(1)

for code in eventcodes:
    if ((code!="P") and (code!="V")):
        Log("ERROR: Invalid event code: "+code)
        print("ERROR: Invalid event code: ",code)
        exit(1)


if (  len(eventcodes) != len(set(eventcodes))   ):
    Log("ERROR: Duplicate found in event codes")
    print("ERROR: Duplicate found in event codes")
    exit(1)

counter=0
for timestr in eventignorestr:
    if timestr=="[*]":
        Log("Event type "+eventcodes[counter]+" will be monitored at all times")
        eventignoretimes.append("[*]")
    else:
        Log("Event type "+eventcodes[counter]+" has a time restriction, checking if times are valid...")
        times=timestr.split("/")
        for  txt in times:
            #txt must be in format "[20:05-20:10]"
            x = re.search(r"^\[\d\d:\d\d-\d\d:\d\d\]$", txt)

            if x:
                print("Ignore time  for event "+eventcodes[counter]+" matches format")
                Log("Ignore time  for event "+eventcodes[counter]+" matches format")
                
                startendtime=splitStartEndTime(txt) #gives list ['HH:MM', 'HH:MM']
                start=splitHoursMinutes(startendtime[0]) #gives list ['HH','MM']
                end=splitHoursMinutes(startendtime[1]) #gives list ['HH','MM']


                if isHour(start[0])==False:
                    print("ERROR: Ignore time Start Hours not OK for event "+eventcodes[counter]+", check 00 to 23")
                    Log("ERROR: Ignore time Start Hours not OK for event "+eventcodes[counter]+", check 00 to 23")
                    exit(1)
                if isMinute(start[1])==False:
                    print("ERROR: Ignore time Start Minutes not OK for event "+eventcodes[counter]+", check 00 to 59")
                    Log("ERROR: Ignore time Start Minutes not OK for event "+eventcodes[counter]+", check 00 to 59")
                    exit(1)
                if isHour(end[0])==False:
                    print("ERROR: Ignore time End Hours not OK for event "+eventcodes[counter]+", check 00 to 23")
                    Log("ERROR: Ignore time End Hours not OK for event "+eventcodes[counter]+", check 00 to 23")
                    exit(1)
                if isMinute(end[1])==False:
                    print("ERROR: Ignore time End Minutes not OK for event "+eventcodes[counter]+", check 00 to 59")
                    Log("ERROR: Ignore time End Minutes not OK for event "+eventcodes[counter]+", check 00 to 59")
                    exit(1)
    
                if startEndSane(start[0],start[1],end[0],end[1])==False:
                    print("ERROR: Ignore time Start and End time are the same for event "+eventcodes[counter])
                    Log("ERROR: Ignore time Start and End time are the same for event "+eventcodes[counter])
                    exit(1)
                    
                if checkOverlap(times):
                    print("ERROR Ignore times overlap for event "+eventcodes[counter])
                    Log("ERROR Ignore times overlap for event "+eventcodes[counter])
                    exit(1)
               
                print("Ignore time  for event "+eventcodes[counter]+" passed all checks")
                Log("Ignore time  for event "+eventcodes[counter]+" passed all checks")
                
            else:
                print("ERROR: Ignore time  for event "+eventcodes[counter]+" does not match format")
                Log("ERROR: Ignore time  for event "+eventcodes[counter]+" does not match format")
                exit(1)
        eventignoretimes.append(times)
    counter=counter+1



cv2.setNumThreads(1)#controls how many threads OpenCVs internal engine uses for any operation it parallelizes internally — things like dnn.forward() etc

#all validated, ready to to look for events
Log("All parameters validated, ready to look for events...")


nvidia=testNVIDIA(cameraurl,ffmpeg_command)
if nvidia==False:
	#test using cuda gpu failed, test camera using ffmpeg on cpu to see if camera available at all
    cmd = [ffmpeg_command,"-timeout","10000000","-hide_banner","-loglevel", "error","-i", cameraurl,"-frames:v", "1","-f", "image2pipe","-vcodec", "mjpeg","-"]  #connect to the camera and get one frame, then quit.
    result = subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=10)

    if result.returncode == 0:
        print("Camera works, ffmpeg connected OK during test using CPU for ffmpeg")
        Log("Camera works, ffmpeg connected OK during test using CPU for ffmpeg")
    else:
        print("Camera failed, ffmpeg could not connect during test")
        Log("Camera failed, ffmpeg could not connect during test")
        print(result.stderr.decode())
        exit(1)
else:        
    print("Camera works, ffmpeg connected OK during test using GPU for ffmpeg")
    Log("Camera works, ffmpeg connected OK during test using GPU for ffmpeg")
    #grab a frame so we can determine the camera resolution below
    cmd = [ffmpeg_command,"-timeout","10000000","-hide_banner","-loglevel", "error","-i", cameraurl,"-frames:v", "1","-f", "image2pipe","-vcodec", "mjpeg","-"]
    result = subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=10)
        
#at this point cameraurl is valid, ffmpeg connected ok and produced a valid frame 

#We need to get frame size this camera is delivering we use the test frame we just got
test_frame = cv2.imdecode(np.frombuffer(result.stdout, dtype=np.uint8), cv2.IMREAD_COLOR)

if test_frame is None:
    print("ERROR: Could not decode test frame to determine camera resolution")
    Log("ERROR: Could not decode test frame to determine camera resolution")
    exit(1)

frame_height, frame_width = test_frame.shape[:2]
print(f"Detected camera resolution: {frame_width}x{frame_height}")
FRAMESIZEBYTES = frame_width * frame_height * 3  # 3 bytes per pixel for bgr24
FRAMEW=frame_width
FRAMEH=frame_height
#handle SIGINT from parent control script
signal.signal(signal.SIGINT,signalHandler)



cvversion=cv2.__version__
cvversionelements=cvversion.split(".")


if (int(cvversionelements[0])<5) and (int(cvversionelements[1])<10):
    print("This program requires OpenCV version 4.10 or later, you are using "+cvversionelements[0]+"."+cvversionelements[1])
    Log("This program requires OpenCV version 4.10 or later, you are using "+cvversionelements[0]+"."+cvversionelements[1])
    exit(1)





modeldir=mydir+"/onnx"
modelPath=modeldir + "/bbrelease.7z"

if ( (fileIsReadable(modelPath)==False)    ):
    print("ERROR: Could not open ONNX model file check the onnx exists and is readable")
    Log("ERROR: Could not open ONNX model file check the onnx exists and is readable")
    exit(1)
    
target = "bbrelease-640x640Q.onnx"

#Embedding the 7z password in the code like this is not meant to be secure its meant to force
#anyone stealing it to read the notice below so they do not inadvertently steal it without knowing
#what they are doing

# WARNING: The ONNX AI model is NOT under GPL. It is covered by the BB AI Model License.       #
# Do not use this password to extract it for use in non BigBrother software unless you         #
# have purchased a commercial license. See www.bigbrothercctv.org/BB-AIMODEL-LICENSE-1.1.txt   #
#                                                                    DO NOT STEAL   READ ^^^   # 
with py7zr.SevenZipFile(modeldir + "/bbrelease.7z",mode="r",password="vR8!qL2#xN6@kT4$mP8&zW6^cH1*Ys5") as archive:

    print("Files inside the 7z archive:")
    print(archive.getnames())
    
    if hasattr(archive, "read"):
        # 0.22 and other older versions
        model_bytes = archive.read([target])[target].read()
    else:
        # 1.0+, where read() was removed
        factory = MemFactory()
        archive.extract(targets=[target], factory=factory)
        model_bytes = factory.files[target].getvalue()

    model_buffer = np.frombuffer(model_bytes, dtype=np.uint8)


 
#setup model
# dictionary with the object class id and names on which the model is trained
classNames = { 0: 'person',1: 'car', 2: 'truck', 3: 'motorcycle', 4: 'van', 5: 'bus', 6: 'bicycle', 7: 'selftest'}

#setup one model (deep neural net) for each thread
maxThreads=1
models=[]
locks=[]


for x in range(maxThreads):
    models.append(loadModel(model_bytes))
    locks.append(threading.Lock())


print("CUDA device count:", cv2.cuda.getCudaEnabledDeviceCount())
Log("CUDA device count: "+str(cv2.cuda.getCudaEnabledDeviceCount()))




#use semaphore to limit number of frames waiting to be processed to limit memory use
maxInFlight = maxThreads
sema = threading.Semaphore(maxInFlight)

executor = ThreadPoolExecutor(max_workers=maxThreads)


now=datetime.now()
starttimestr=now.strftime("%Y-%m-%d %H:%M:%S")


#setup and start housekeeping thread to trim detections[] array
cleaner=threading.Thread(target=housekeeping, args=())
cleaner.start()

#load video

framereaderrorcount = 0
threadidx = 0



frame_queue = Queue(maxsize=20)

#reset watchdog timer now, startup (7z extraction, model load and test run) can take longer than
#the 15 sec watchdog timeout on slow CPUs, which would otherwise trigger a false camera timeout
last_frame_time = time.time()

threading.Thread(target=reader, daemon=True).start()
threading.Thread(target=watchdog, daemon=True).start()

prev_small = None     #small greyscale copy of previous frame, for motion detection
pending_frame = None  #frame with most motion seen while the model was busy
pending_score = -1.0

while not exit_event.is_set():

    try:
        frame = frame_queue.get(timeout=1)
    except Empty:
        frame = None

    if frame is not None:
        #cheap motion score: fraction of pixels that changed since the previous frame, on a tiny copy
        small = cv2.cvtColor(cv2.resize(frame, (160, 90), interpolation=cv2.INTER_NEAREST), cv2.COLOR_BGR2GRAY)
        if prev_small is None:
            score = 0.0
        else:
            score = np.count_nonzero(cv2.absdiff(small, prev_small) > 25) / small.size
        prev_small = small

        #keep the frame with the most motion until the model is free to process it
        if pending_frame is None or score > pending_score:
            pending_frame = frame
            pending_score = score

    if pending_frame is not None and sema.acquire(blocking=False):
        executor.submit(processFrameWrapper, threadidx, pending_frame)
        pending_frame = None
        pending_score = -1.0

        if threadidx == (maxThreads - 1):
            threadidx = 0
        else:
            threadidx += 1


executor.shutdown(wait=True)

print("Acquired all thread locks so all threads stopped")

print("Exiting")
Log("Exiting")
exit(0)
