First commit

This commit is contained in:
proitlab committed 2026-06-30 18:49:39 +07:00
1 parent 4f70c7f8d5
commit dc8dcca75e
17 files changed
+4382 -2

No files matched your search

+467
View File
@@ -0,0 +1,467 @@
package batchstore
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
type LogFunc func(string)
type Store struct {
dbPath string
stateFile string
cameraName string
objectLabel string
cutoffTime string
batchTimeout time.Duration
ignoreBatchLabelTimeout time.Duration
minObjectPerBatch int
minDurationPerBatch int
carryIDs int
log LogFunc
db *sql.DB
mu sync.Mutex
currentState *batchState
previousState *batchState
batchTimer *time.Timer
ignoreBatchLabel bool
ignoreBatchLabelTimer *time.Timer
shutdownCh chan struct{}
stopped bool
}
type batchState struct {
CountingDate string `json:"counting_date"`
BatchNumber int `json:"batch_number"`
Count int `json:"count"`
StartTime string `json:"start_time"`
LastDetection string `json:"last_detection_time"`
CountedEventIDs []string `json:"counted_event_ids"`
}
type Config struct {
DBPath string
StateFile string
CameraName string
ObjectLabel string
CutoffTime string
BatchTimeoutSec float64
IgnoreBatchLabelTimeout float64
MinObjectPerBatch int
MinDurationPerBatch int
}
func New(cfg Config, logFn LogFunc) (*Store, error) {
if logFn == nil {
logFn = func(s string) { fmt.Println(s) }
}
s := &Store{
dbPath: cfg.DBPath,
stateFile: cfg.StateFile,
cameraName: cfg.CameraName,
objectLabel: cfg.ObjectLabel,
cutoffTime: cfg.CutoffTime,
batchTimeout: time.Duration(cfg.BatchTimeoutSec * float64(time.Second)),
ignoreBatchLabelTimeout: time.Duration(cfg.IgnoreBatchLabelTimeout * float64(time.Second)),
minObjectPerBatch: cfg.MinObjectPerBatch,
minDurationPerBatch: cfg.MinDurationPerBatch,
carryIDs: 50,
log: logFn,
shutdownCh: make(chan struct{}),
}
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0755); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(cfg.StateFile), 0755); err != nil {
return nil, err
}
var err error
s.db, err = sql.Open("sqlite", cfg.DBPath+"?_journal_mode=WAL&_synchronous=NORMAL")
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if err := s.initDB(); err != nil {
return nil, err
}
s.currentState = s.loadState()
s.previousState = s.currentState
return s, nil
}
func (s *Store) initDB() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counting_date TEXT NOT NULL,
batch_number INTEGER NOT NULL,
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
count INTEGER NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(counting_date, batch_number, camera_name, object_label)
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS daily_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counting_date TEXT NOT NULL,
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
total_batches INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(counting_date, camera_name, object_label)
)
`)
return err
}
func (s *Store) countingDate(t time.Time) string {
cutoff, _ := time.Parse("15:04", s.cutoffTime)
ct := time.Date(t.Year(), t.Month(), t.Day(), cutoff.Hour(), cutoff.Minute(), 0, 0, t.Location())
if t.Before(ct) {
return t.Format("2006-01-02")
}
return t.Add(24 * time.Hour).Format("2006-01-02")
}
func (s *Store) loadState() *batchState {
data, err := os.ReadFile(s.stateFile)
if err != nil {
return nil
}
var st batchState
if err := json.Unmarshal(data, &st); err != nil {
s.log(fmt.Sprintf("Failed to load state file: %v", err))
return nil
}
currentDate := s.countingDate(time.Now())
if st.CountingDate != currentDate {
s.log(fmt.Sprintf("State file belongs to previous counting day (%s). Finalizing before fresh start.", st.CountingDate))
s.insertBatch(st.CountingDate, st.BatchNumber, st.Count, st.StartTime, time.Now().Format(time.RFC3339))
os.Remove(s.stateFile)
return nil
}
s.log(fmt.Sprintf("Resumed batch #%d from %s with count=%d", st.BatchNumber, st.StartTime, st.Count))
s.resetBatchTimer()
return &st
}
func (s *Store) saveState() {
if s.currentState == nil {
os.Remove(s.stateFile)
return
}
data, err := json.MarshalIndent(s.currentState, "", " ")
if err != nil {
return
}
if err := os.WriteFile(s.stateFile, data, 0644); err != nil {
s.log(fmt.Sprintf("Failed to save state: %v", err))
}
}
func (s *Store) nextBatchNumber(countingDate string) int {
var maxNum sql.NullInt64
err := s.db.QueryRow(
`SELECT MAX(batch_number) FROM batches WHERE counting_date = ? AND camera_name = ? AND object_label = ?`,
countingDate, s.cameraName, s.objectLabel,
).Scan(&maxNum)
if err != nil || !maxNum.Valid {
return 1
}
return int(maxNum.Int64) + 1
}
func (s *Store) startNewBatch(countingDate string) {
batchNum := s.nextBatchNumber(countingDate)
now := time.Now().Format(time.RFC3339)
var countedIDs []string
if s.previousState != nil && len(s.previousState.CountedEventIDs) > 0 {
ids := s.previousState.CountedEventIDs
start := 0
if len(ids) > s.carryIDs {
start = len(ids) - s.carryIDs
}
countedIDs = ids[start:]
}
s.currentState = &batchState{
CountingDate: countingDate,
BatchNumber: batchNum,
Count: 0,
StartTime: now,
LastDetection: now,
CountedEventIDs: countedIDs,
}
s.saveState()
s.log(fmt.Sprintf("Started batch #%d for %s (%s)", batchNum, countingDate, s.objectLabel))
}
func (s *Store) resetBatchTimer() {
if s.batchTimer != nil {
s.batchTimer.Stop()
}
s.batchTimer = time.AfterFunc(s.batchTimeout, func() {
s.log(fmt.Sprintf("Batch inactivity timeout (%.0fs) reached", s.batchTimeout.Seconds()))
s.endBatch("timeout")
})
}
func (s *Store) startIgnoreBatchLabel() {
if s.ignoreBatchLabelTimer != nil {
return
}
s.ignoreBatchLabel = true
s.ignoreBatchLabelTimer = time.AfterFunc(s.ignoreBatchLabelTimeout, func() {
s.ignoreBatchLabel = false
s.ignoreBatchLabelTimer = nil
s.log("Ignore batch label cooldown finished")
})
s.log(fmt.Sprintf("Ignore batch label for %.0fs", s.ignoreBatchLabelTimeout.Seconds()))
}
func (s *Store) RecordAyamCrossing(trackID int) (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
countingDate := s.countingDate(time.Now())
startedNew := false
if s.currentState == nil {
s.startNewBatch(countingDate)
startedNew = true
} else if s.currentState.CountingDate != countingDate {
s.endBatchLocked("cutoff")
s.startNewBatch(countingDate)
startedNew = true
}
eventKey := fmt.Sprintf("%d", trackID)
found := false
for _, id := range s.currentState.CountedEventIDs {
if id == eventKey {
found = true
break
}
}
if !found {
s.currentState.Count++
s.currentState.CountedEventIDs = append(s.currentState.CountedEventIDs, eventKey)
s.log(fmt.Sprintf("Counted ayam (track %d) | batch #%d total: %d", trackID, s.currentState.BatchNumber, s.currentState.Count))
}
s.currentState.LastDetection = time.Now().Format(time.RFC3339)
s.saveState()
s.resetBatchTimer()
return s.currentState.Count, startedNew
}
func (s *Store) RecordTalenanCrossing(trackID int) bool {
if s.ignoreBatchLabel {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
s.startIgnoreBatchLabel()
s.endBatchLocked("talenan")
s.log(fmt.Sprintf("Batch closed by talenan (track %d)", trackID))
if s.batchTimer != nil {
s.batchTimer.Stop()
s.batchTimer = nil
}
return true
}
func (s *Store) endBatch(closedBy string) {
s.mu.Lock()
defer s.mu.Unlock()
s.endBatchLocked(closedBy)
}
func (s *Store) endBatchLocked(closedBy string) bool {
if s.currentState == nil {
return false
}
s.previousState = s.currentState
state := s.currentState
startTime, _ := time.Parse(time.RFC3339, state.StartTime)
endTime := time.Now()
durationSec := endTime.Sub(startTime).Seconds()
if state.Count < s.minObjectPerBatch || int(durationSec) < s.minDurationPerBatch {
s.currentState = nil
s.saveState()
if s.batchTimer != nil {
s.batchTimer.Stop()
s.batchTimer = nil
}
s.log(fmt.Sprintf("Batch #%d discarded (count=%d, duration=%.0fs)", state.BatchNumber, state.Count, durationSec))
return false
}
endTimeStr := endTime.Format(time.RFC3339)
if err := s.insertBatch(state.CountingDate, state.BatchNumber, state.Count, state.StartTime, endTimeStr); err != nil {
s.log(fmt.Sprintf("Failed to persist batch: %v", err))
return false
}
cps := float64(state.Count) / durationSec
if durationSec == 0 {
cps = 0
}
s.log(fmt.Sprintf("Batch #%d ended | count=%d | duration=%.0fs | cps=%.3f | closed_by=%s",
state.BatchNumber, state.Count, durationSec, cps, closedBy))
s.currentState = nil
s.saveState()
if s.batchTimer != nil {
s.batchTimer.Stop()
s.batchTimer = nil
}
return true
}
func (s *Store) insertBatch(countingDate string, batchNumber, count int, startTime, endTime string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
`INSERT INTO batches (counting_date, batch_number, camera_name, object_label, count, start_time, end_time)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
countingDate, batchNumber, s.cameraName, s.objectLabel, count, startTime, endTime,
)
if err != nil {
return err
}
_, err = tx.Exec(
`INSERT INTO daily_summaries (counting_date, camera_name, object_label, total_count, total_batches)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT(counting_date, camera_name, object_label)
DO UPDATE SET total_count = total_count + excluded.total_count,
total_batches = total_batches + excluded.total_batches,
updated_at = CURRENT_TIMESTAMP`,
countingDate, s.cameraName, s.objectLabel, count,
)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
var totalCount, totalBatches int
_ = s.db.QueryRow(
`SELECT total_count, total_batches FROM daily_summaries
WHERE counting_date = ? AND camera_name = ? AND object_label = ?`,
countingDate, s.cameraName, s.objectLabel,
).Scan(&totalCount, &totalBatches)
s.log(fmt.Sprintf("Daily totals for %s: %d objects across %d batch(es)", countingDate, totalCount, totalBatches))
return nil
}
func (s *Store) cutoffWatcher() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.shutdownCh:
return
case <-ticker.C:
s.mu.Lock()
if s.currentState != nil && s.currentState.CountingDate != s.countingDate(time.Now()) {
s.log("Daily cutoff reached – finalizing batch")
s.endBatchLocked("cutoff")
}
s.mu.Unlock()
}
}
}
func (s *Store) StartCutoffWatcher() {
go s.cutoffWatcher()
}
func (s *Store) CurrentBatchNumber() int {
s.mu.Lock()
defer s.mu.Unlock()
if s.currentState == nil {
return 0
}
return s.currentState.BatchNumber
}
func (s *Store) CurrentBatchCount() int {
s.mu.Lock()
defer s.mu.Unlock()
if s.currentState == nil {
return 0
}
return s.currentState.Count
}
func (s *Store) ClosedTotalForDay() int {
countingDate := s.countingDate(time.Now())
var total int
_ = s.db.QueryRow(
`SELECT COALESCE(total_count, 0) FROM daily_summaries
WHERE counting_date = ? AND camera_name = ? AND object_label = ?`,
countingDate, s.cameraName, s.objectLabel,
).Scan(&total)
return total
}
func (s *Store) DisplayTotal() int {
return s.ClosedTotalForDay() + s.CurrentBatchCount()
}
func (s *Store) Shutdown() {
if s.stopped {
return
}
s.stopped = true
close(s.shutdownCh)
s.endBatch("shutdown")
if s.batchTimer != nil {
s.batchTimer.Stop()
}
if s.db != nil {
s.db.Close()
}
}