nedo-vision-worker-core 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nedo-vision-worker-core might be problematic. Click here for more details.

Files changed (95) hide show
  1. nedo_vision_worker_core/__init__.py +23 -0
  2. nedo_vision_worker_core/ai/FrameDrawer.py +144 -0
  3. nedo_vision_worker_core/ai/ImageDebugger.py +126 -0
  4. nedo_vision_worker_core/ai/VideoDebugger.py +69 -0
  5. nedo_vision_worker_core/ai/__init__.py +1 -0
  6. nedo_vision_worker_core/cli.py +197 -0
  7. nedo_vision_worker_core/config/ConfigurationManager.py +173 -0
  8. nedo_vision_worker_core/config/__init__.py +1 -0
  9. nedo_vision_worker_core/core_service.py +237 -0
  10. nedo_vision_worker_core/database/DatabaseManager.py +236 -0
  11. nedo_vision_worker_core/database/__init__.py +1 -0
  12. nedo_vision_worker_core/detection/BaseDetector.py +22 -0
  13. nedo_vision_worker_core/detection/DetectionManager.py +83 -0
  14. nedo_vision_worker_core/detection/RFDETRDetector.py +62 -0
  15. nedo_vision_worker_core/detection/YOLODetector.py +57 -0
  16. nedo_vision_worker_core/detection/__init__.py +1 -0
  17. nedo_vision_worker_core/detection/detection_processing/DetectionProcessor.py +29 -0
  18. nedo_vision_worker_core/detection/detection_processing/HumanDetectionProcessor.py +47 -0
  19. nedo_vision_worker_core/detection/detection_processing/PPEDetectionProcessor.py +44 -0
  20. nedo_vision_worker_core/detection/detection_processing/__init__.py +1 -0
  21. nedo_vision_worker_core/doctor.py +342 -0
  22. nedo_vision_worker_core/drawing_assets/blue/inner_corner.png +0 -0
  23. nedo_vision_worker_core/drawing_assets/blue/inner_frame.png +0 -0
  24. nedo_vision_worker_core/drawing_assets/blue/line.png +0 -0
  25. nedo_vision_worker_core/drawing_assets/blue/top_left.png +0 -0
  26. nedo_vision_worker_core/drawing_assets/blue/top_right.png +0 -0
  27. nedo_vision_worker_core/drawing_assets/red/inner_corner.png +0 -0
  28. nedo_vision_worker_core/drawing_assets/red/inner_frame.png +0 -0
  29. nedo_vision_worker_core/drawing_assets/red/line.png +0 -0
  30. nedo_vision_worker_core/drawing_assets/red/top_left.png +0 -0
  31. nedo_vision_worker_core/drawing_assets/red/top_right.png +0 -0
  32. nedo_vision_worker_core/icons/boots-green.png +0 -0
  33. nedo_vision_worker_core/icons/boots-red.png +0 -0
  34. nedo_vision_worker_core/icons/gloves-green.png +0 -0
  35. nedo_vision_worker_core/icons/gloves-red.png +0 -0
  36. nedo_vision_worker_core/icons/goggles-green.png +0 -0
  37. nedo_vision_worker_core/icons/goggles-red.png +0 -0
  38. nedo_vision_worker_core/icons/helmet-green.png +0 -0
  39. nedo_vision_worker_core/icons/helmet-red.png +0 -0
  40. nedo_vision_worker_core/icons/mask-red.png +0 -0
  41. nedo_vision_worker_core/icons/vest-green.png +0 -0
  42. nedo_vision_worker_core/icons/vest-red.png +0 -0
  43. nedo_vision_worker_core/models/__init__.py +20 -0
  44. nedo_vision_worker_core/models/ai_model.py +41 -0
  45. nedo_vision_worker_core/models/auth.py +14 -0
  46. nedo_vision_worker_core/models/config.py +9 -0
  47. nedo_vision_worker_core/models/dataset_source.py +30 -0
  48. nedo_vision_worker_core/models/logs.py +9 -0
  49. nedo_vision_worker_core/models/ppe_detection.py +39 -0
  50. nedo_vision_worker_core/models/ppe_detection_label.py +20 -0
  51. nedo_vision_worker_core/models/restricted_area_violation.py +20 -0
  52. nedo_vision_worker_core/models/user.py +10 -0
  53. nedo_vision_worker_core/models/worker_source.py +19 -0
  54. nedo_vision_worker_core/models/worker_source_pipeline.py +21 -0
  55. nedo_vision_worker_core/models/worker_source_pipeline_config.py +24 -0
  56. nedo_vision_worker_core/models/worker_source_pipeline_debug.py +15 -0
  57. nedo_vision_worker_core/models/worker_source_pipeline_detection.py +14 -0
  58. nedo_vision_worker_core/pipeline/PipelineConfigManager.py +32 -0
  59. nedo_vision_worker_core/pipeline/PipelineManager.py +133 -0
  60. nedo_vision_worker_core/pipeline/PipelinePrepocessor.py +40 -0
  61. nedo_vision_worker_core/pipeline/PipelineProcessor.py +338 -0
  62. nedo_vision_worker_core/pipeline/PipelineSyncThread.py +202 -0
  63. nedo_vision_worker_core/pipeline/__init__.py +1 -0
  64. nedo_vision_worker_core/preprocessing/ImageResizer.py +42 -0
  65. nedo_vision_worker_core/preprocessing/ImageRoi.py +61 -0
  66. nedo_vision_worker_core/preprocessing/Preprocessor.py +16 -0
  67. nedo_vision_worker_core/preprocessing/__init__.py +1 -0
  68. nedo_vision_worker_core/repositories/AIModelRepository.py +31 -0
  69. nedo_vision_worker_core/repositories/PPEDetectionRepository.py +146 -0
  70. nedo_vision_worker_core/repositories/RestrictedAreaRepository.py +90 -0
  71. nedo_vision_worker_core/repositories/WorkerSourcePipelineDebugRepository.py +81 -0
  72. nedo_vision_worker_core/repositories/WorkerSourcePipelineDetectionRepository.py +71 -0
  73. nedo_vision_worker_core/repositories/WorkerSourcePipelineRepository.py +79 -0
  74. nedo_vision_worker_core/repositories/WorkerSourceRepository.py +19 -0
  75. nedo_vision_worker_core/repositories/__init__.py +1 -0
  76. nedo_vision_worker_core/streams/RTMPStreamer.py +146 -0
  77. nedo_vision_worker_core/streams/StreamSyncThread.py +66 -0
  78. nedo_vision_worker_core/streams/VideoStream.py +324 -0
  79. nedo_vision_worker_core/streams/VideoStreamManager.py +121 -0
  80. nedo_vision_worker_core/streams/__init__.py +1 -0
  81. nedo_vision_worker_core/tracker/SFSORT.py +325 -0
  82. nedo_vision_worker_core/tracker/TrackerManager.py +163 -0
  83. nedo_vision_worker_core/tracker/__init__.py +1 -0
  84. nedo_vision_worker_core/util/BoundingBoxMetrics.py +53 -0
  85. nedo_vision_worker_core/util/DrawingUtils.py +354 -0
  86. nedo_vision_worker_core/util/ModelReadinessChecker.py +188 -0
  87. nedo_vision_worker_core/util/PersonAttributeMatcher.py +70 -0
  88. nedo_vision_worker_core/util/PersonRestrictedAreaMatcher.py +45 -0
  89. nedo_vision_worker_core/util/TablePrinter.py +28 -0
  90. nedo_vision_worker_core/util/__init__.py +1 -0
  91. nedo_vision_worker_core-0.2.0.dist-info/METADATA +347 -0
  92. nedo_vision_worker_core-0.2.0.dist-info/RECORD +95 -0
  93. nedo_vision_worker_core-0.2.0.dist-info/WHEEL +5 -0
  94. nedo_vision_worker_core-0.2.0.dist-info/entry_points.txt +2 -0
  95. nedo_vision_worker_core-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,173 @@
1
+ import logging
2
+ from .models.config import ConfigEntity
3
+ from ..database.DatabaseManager import DatabaseManager # DatabaseManager for managing sessions
4
+
5
+
6
+ class ConfigurationManager:
7
+ """
8
+ A class to manage server configuration stored in the 'config' database using SQLAlchemy.
9
+ """
10
+
11
+ @staticmethod
12
+ def init_database():
13
+ """
14
+ Initialize the 'config' database and create the `server_config` table if it doesn't exist.
15
+ """
16
+ try:
17
+ DatabaseManager.init_databases()
18
+ logging.info("Configuration database initialized successfully.")
19
+ except Exception as e:
20
+ logging.exception("Failed to initialize the configuration database.")
21
+ raise RuntimeError("Database initialization failed.") from e
22
+
23
+ @staticmethod
24
+ def set_config(key: str, value: str):
25
+ """
26
+ Set or update a configuration key-value pair in the 'config' database.
27
+
28
+ Args:
29
+ key (str): The configuration key.
30
+ value (str): The configuration value.
31
+ """
32
+ if not key or not isinstance(key, str):
33
+ raise ValueError("The 'key' must be a non-empty string.")
34
+ if not isinstance(value, str):
35
+ raise ValueError("The 'value' must be a string.")
36
+
37
+ session = None
38
+ try:
39
+ session = DatabaseManager.get_session("config")
40
+ logging.info(f"Attempting to set configuration: {key} = {value}")
41
+ existing_config = session.query(ConfigEntity).filter_by(key=key).first()
42
+ if existing_config:
43
+ logging.info(f"Updating configuration key: {key}")
44
+ existing_config.value = value
45
+ else:
46
+ logging.info(f"Adding new configuration key: {key}")
47
+ new_config = ConfigEntity(key=key, value=value)
48
+ session.add(new_config)
49
+ session.commit()
50
+ logging.info(f"Configuration key '{key}' set successfully.")
51
+ except Exception as e:
52
+ if session:
53
+ session.rollback()
54
+ logging.exception(f"Failed to set configuration key '{key}': {e}")
55
+ raise RuntimeError(f"Failed to set configuration key '{key}'") from e
56
+ finally:
57
+ if session:
58
+ session.close()
59
+
60
+ @staticmethod
61
+ def set_config_batch(configs: dict):
62
+ """
63
+ Set or update multiple configuration key-value pairs in the 'config' database in a batch operation.
64
+
65
+ Args:
66
+ configs (dict): A dictionary containing configuration key-value pairs.
67
+ """
68
+ if not isinstance(configs, dict) or not configs:
69
+ raise ValueError("The 'configs' parameter must be a non-empty dictionary.")
70
+
71
+ session = None
72
+ try:
73
+ session = DatabaseManager.get_session("config")
74
+ logging.info(f"Attempting to set {len(configs)} configuration keys in batch.")
75
+
76
+ # Retrieve existing configurations
77
+ existing_configs = session.query(ConfigEntity).filter(ConfigEntity.key.in_(configs.keys())).all()
78
+ existing_keys = {config.key: config for config in existing_configs}
79
+
80
+ for key, value in configs.items():
81
+ if key in existing_keys:
82
+ logging.info(f"Updating configuration key: {key}")
83
+ existing_keys[key].value = value
84
+ else:
85
+ logging.info(f"Adding new configuration key: {key}")
86
+ new_config = ConfigEntity(key=key, value=value)
87
+ session.add(new_config)
88
+
89
+ session.commit()
90
+ logging.info("All configuration keys set successfully.")
91
+ except Exception as e:
92
+ if session:
93
+ session.rollback()
94
+ logging.exception(f"Failed to set batch configuration keys: {e}")
95
+ raise RuntimeError("Failed to set batch configuration keys.") from e
96
+ finally:
97
+ if session:
98
+ session.close()
99
+
100
+ @staticmethod
101
+ def get_config(key: str) -> str:
102
+ """
103
+ Retrieve the value of a specific configuration key from the 'config' database.
104
+
105
+ Args:
106
+ key (str): The configuration key.
107
+
108
+ Returns:
109
+ str: The configuration value, or None if the key does not exist.
110
+ """
111
+ if not key or not isinstance(key, str):
112
+ raise ValueError("The 'key' must be a non-empty string.")
113
+
114
+ session = None
115
+ try:
116
+ session = DatabaseManager.get_session("config")
117
+ logging.info(f"Retrieving configuration key: {key}")
118
+ config = session.query(ConfigEntity).filter_by(key=key).first()
119
+ if config:
120
+ logging.info(f"Configuration key '{key}' retrieved successfully.")
121
+ return config.value
122
+ else:
123
+ logging.warning(f"Configuration key '{key}' not found.")
124
+ return None
125
+ except Exception as e:
126
+ logging.exception(f"Failed to retrieve configuration key '{key}': {e}")
127
+ raise RuntimeError(f"Failed to retrieve configuration key '{key}'") from e
128
+ finally:
129
+ if session:
130
+ session.close()
131
+
132
+ @staticmethod
133
+ def get_all_configs() -> dict:
134
+ """
135
+ Retrieve all configuration key-value pairs from the 'config' database.
136
+
137
+ Returns:
138
+ dict: A dictionary of all configuration key-value pairs.
139
+ """
140
+ session = None
141
+ try:
142
+ session = DatabaseManager.get_session("config")
143
+ logging.info("Retrieving all configuration keys.")
144
+ configs = session.query(ConfigEntity).all()
145
+ if configs:
146
+ logging.info("All configuration keys retrieved successfully.")
147
+ return {config.key: config.value for config in configs}
148
+ else:
149
+ logging.info("No configuration keys found.")
150
+ return {}
151
+ except Exception as e:
152
+ logging.exception("Failed to retrieve all configuration keys.")
153
+ raise RuntimeError("Failed to retrieve all configuration keys.") from e
154
+ finally:
155
+ if session:
156
+ session.close()
157
+
158
+ @staticmethod
159
+ def print_config():
160
+ """
161
+ Print all configuration key-value pairs to the console.
162
+ """
163
+ try:
164
+ configs = ConfigurationManager.get_all_configs()
165
+ if configs:
166
+ print("Current Configuration:")
167
+ for key, value in configs.items():
168
+ print(f" {key}: {value}")
169
+ else:
170
+ print("No configuration found. Please initialize the configuration.")
171
+ except Exception as e:
172
+ logging.exception("Failed to print configuration keys.")
173
+ raise RuntimeError("Failed to print configuration keys.") from e
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,237 @@
1
+ import logging
2
+ import time
3
+ import signal
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Callable, Dict, List, Any
7
+
8
+ from .util.DrawingUtils import DrawingUtils
9
+ from .streams.VideoStreamManager import VideoStreamManager
10
+ from .streams.StreamSyncThread import StreamSyncThread
11
+ from .pipeline.PipelineSyncThread import PipelineSyncThread
12
+ from .database.DatabaseManager import DatabaseManager
13
+ # Import models to ensure they are registered with SQLAlchemy Base registry
14
+ from . import models
15
+ import cv2
16
+
17
+
18
+ class CoreService:
19
+ """Service class for running the Nedo Vision Core processing."""
20
+
21
+ # Class-level callback registry for detection events
22
+ _detection_callbacks: Dict[str, List[Callable]] = {
23
+ 'ppe_detection': [],
24
+ 'area_violation': [],
25
+ 'general_detection': []
26
+ }
27
+
28
+ def __init__(self,
29
+ drawing_assets_path: str = None,
30
+ log_level: str = "INFO",
31
+ storage_path: str = "data",
32
+ rtmp_server: str = "rtmp://live.vision.sindika.co.id:1935/live"):
33
+ """
34
+ Initialize the Core Service.
35
+
36
+ Args:
37
+ drawing_assets_path: Path to drawing assets directory (optional, uses bundled assets by default)
38
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
39
+ storage_path: Storage path for databases and files (default: data)
40
+ rtmp_server: RTMP server URL for video streaming (default: rtmp://localhost:1935/live)
41
+ """
42
+ self.running = True
43
+ self.video_manager = None
44
+ self.stream_sync_thread = None
45
+ self.pipeline_sync_thread = None
46
+
47
+ # Store configuration parameters
48
+ self.storage_path = storage_path
49
+ self.rtmp_server = rtmp_server
50
+
51
+ # Use bundled drawing assets by default
52
+ if drawing_assets_path is None:
53
+ # Get the path to the bundled drawing assets
54
+ current_dir = Path(__file__).parent
55
+ self.drawing_assets_path = str(current_dir / "drawing_assets")
56
+ else:
57
+ self.drawing_assets_path = drawing_assets_path
58
+
59
+ self.log_level = log_level
60
+
61
+ # Set up logging
62
+ self._setup_logging()
63
+
64
+ # Set up signal handlers
65
+ signal.signal(signal.SIGINT, self._signal_handler)
66
+ signal.signal(signal.SIGTERM, self._signal_handler)
67
+
68
+ @classmethod
69
+ def register_detection_callback(cls, detection_type: str, callback: Callable[[Dict[str, Any]], None]):
70
+ """
71
+ Register a callback for detection events.
72
+
73
+ Args:
74
+ detection_type: Type of detection ('ppe_detection', 'area_violation', 'general_detection')
75
+ callback: Function to call when detection occurs. Should accept a dict with detection data.
76
+ """
77
+ if detection_type not in cls._detection_callbacks:
78
+ cls._detection_callbacks[detection_type] = []
79
+
80
+ cls._detection_callbacks[detection_type].append(callback)
81
+ logging.info(f"📞 Registered {detection_type} callback: {callback.__name__}")
82
+
83
+ @classmethod
84
+ def unregister_detection_callback(cls, detection_type: str, callback: Callable[[Dict[str, Any]], None]):
85
+ """
86
+ Unregister a callback for detection events.
87
+
88
+ Args:
89
+ detection_type: Type of detection ('ppe_detection', 'area_violation', 'general_detection')
90
+ callback: Function to unregister
91
+ """
92
+ if detection_type in cls._detection_callbacks and callback in cls._detection_callbacks[detection_type]:
93
+ cls._detection_callbacks[detection_type].remove(callback)
94
+ logging.info(f"📞 Unregistered {detection_type} callback: {callback.__name__}")
95
+
96
+ @classmethod
97
+ def trigger_detection_callback(cls, detection_type: str, detection_data: Dict[str, Any]):
98
+ """
99
+ Trigger all registered callbacks for a detection type.
100
+
101
+ Args:
102
+ detection_type: Type of detection that occurred
103
+ detection_data: Dict containing detection information
104
+ """
105
+ callbacks = cls._detection_callbacks.get(detection_type, [])
106
+ general_callbacks = cls._detection_callbacks.get('general_detection', [])
107
+
108
+ # Call specific callbacks
109
+ for callback in callbacks:
110
+ try:
111
+ callback(detection_data)
112
+ except Exception as e:
113
+ logging.error(f"❌ Error in {detection_type} callback {callback.__name__}: {e}")
114
+
115
+ # Call general callbacks
116
+ for callback in general_callbacks:
117
+ try:
118
+ callback(detection_data)
119
+ except Exception as e:
120
+ logging.error(f"❌ Error in general detection callback {callback.__name__}: {e}")
121
+
122
+ @classmethod
123
+ def list_callbacks(cls) -> Dict[str, List[str]]:
124
+ """
125
+ List all registered callbacks.
126
+
127
+ Returns:
128
+ Dict mapping detection types to callback function names
129
+ """
130
+ return {
131
+ detection_type: [callback.__name__ for callback in callbacks]
132
+ for detection_type, callbacks in cls._detection_callbacks.items()
133
+ if callbacks
134
+ }
135
+
136
+ def _setup_environment(self):
137
+ """Set up environment variables for components that still require them (like RTMPStreamer)."""
138
+ os.environ["STORAGE_PATH"] = self.storage_path
139
+ os.environ["RTMP_SERVER"] = self.rtmp_server
140
+
141
+ def _setup_logging(self):
142
+ """Configure logging settings."""
143
+ logging.basicConfig(
144
+ level=getattr(logging, self.log_level.upper()),
145
+ format="%(asctime)s [%(levelname)s] %(message)s",
146
+ datefmt="%Y-%m-%d %H:%M:%S",
147
+ )
148
+ logging.getLogger("sqlalchemy").setLevel(logging.WARNING)
149
+ logging.getLogger("pika").setLevel(logging.WARNING)
150
+ logging.getLogger("grpc").setLevel(logging.FATAL)
151
+ logging.getLogger("ffmpeg").setLevel(logging.FATAL)
152
+ logging.getLogger("subprocess").setLevel(logging.FATAL)
153
+
154
+ def _signal_handler(self, signum, frame):
155
+ """Handle system signals for graceful shutdown."""
156
+ logging.info(f"Received signal {signum}, shutting down...")
157
+ self.stop()
158
+
159
+ def initialize(self):
160
+ """Initialize all application components."""
161
+ logging.info("🚀 Initializing Nedo Vision Core components...")
162
+
163
+ try:
164
+ # Set up environment variables for internal components that still need them
165
+ self._setup_environment()
166
+
167
+ # Initialize Database with storage path
168
+ DatabaseManager.init_databases(storage_path=self.storage_path)
169
+
170
+ # Initialize Drawing Utils
171
+ DrawingUtils.initialize(self.drawing_assets_path)
172
+
173
+ # Initialize Video Stream Manager
174
+ self.video_manager = VideoStreamManager()
175
+
176
+ # Start stream synchronization thread
177
+ self.stream_sync_thread = StreamSyncThread(self.video_manager)
178
+ self.stream_sync_thread.start()
179
+
180
+ # Start pipeline synchronization thread (AI processing)
181
+ self.pipeline_sync_thread = PipelineSyncThread(self.video_manager)
182
+ self.pipeline_sync_thread.start()
183
+
184
+ logging.info("✅ Nedo Vision Core initialized and running.")
185
+ return True
186
+
187
+ except Exception as e:
188
+ logging.error(f"❌ Failed to initialize components: {e}", exc_info=True)
189
+ return False
190
+
191
+ def run(self):
192
+ """Run the main application loop."""
193
+ if not self.initialize():
194
+ logging.error("❌ Failed to initialize, exiting...")
195
+ return False
196
+
197
+ try:
198
+ logging.info("🔄 Core service is running. Press Ctrl+C to stop.")
199
+ while self.running:
200
+ time.sleep(1)
201
+ return True
202
+ except KeyboardInterrupt:
203
+ logging.info("🛑 Interrupt received, shutting down...")
204
+ return True
205
+ except Exception as e:
206
+ logging.error(f"❌ Unexpected error: {e}", exc_info=True)
207
+ return False
208
+ finally:
209
+ self.stop()
210
+
211
+ def stop(self):
212
+ """Stop all application components gracefully."""
213
+ logging.info("🛑 Stopping Nedo Vision Core...")
214
+
215
+ self.running = False
216
+
217
+ try:
218
+ if self.stream_sync_thread:
219
+ self.stream_sync_thread.running = False
220
+ self.stream_sync_thread.join(timeout=5)
221
+
222
+ if self.pipeline_sync_thread:
223
+ self.pipeline_sync_thread.running = False
224
+ self.pipeline_sync_thread.join(timeout=5)
225
+
226
+ if self.video_manager:
227
+ self.video_manager.stop_all()
228
+
229
+ # Final cleanup
230
+ cv2.destroyAllWindows()
231
+ for _ in range(5): # Force windows to close
232
+ cv2.waitKey(1)
233
+
234
+ except Exception as e:
235
+ logging.error(f"Error during shutdown: {e}")
236
+ finally:
237
+ logging.info("✅ Nedo Vision Core shutdown complete.")
@@ -0,0 +1,236 @@
1
+ import os
2
+ from alembic.migration import MigrationContext
3
+ from alembic.operations import Operations
4
+ from alembic.operations.ops import AlterColumnOp
5
+ from alembic.autogenerate import produce_migrations
6
+ from sqlalchemy import Column, Connection, Table, create_engine, inspect, MetaData, select, text
7
+ from sqlalchemy.orm import sessionmaker, declarative_base, scoped_session
8
+ from pathlib import Path
9
+ import logging
10
+
11
+ # Add dotenv for environment loading
12
+ try:
13
+ from dotenv import load_dotenv
14
+ except ImportError:
15
+ load_dotenv = None
16
+
17
+ # Base class for ORM models
18
+ Base = declarative_base()
19
+
20
+ # Dictionary to hold engines and session factories
21
+ ENGINES = {}
22
+ SESSION_FACTORIES = {}
23
+ _initialized = False # To track whether databases have been initialized
24
+
25
+ def alter_drop_not_null_sqlite(conn: Connection, table_name: str, column_name: str):
26
+ # Load existing metadata
27
+ metadata = MetaData()
28
+ metadata.reflect(conn)
29
+
30
+ table = metadata.tables[table_name]
31
+
32
+ # Create new table with updated schema (column without NOT NULL)
33
+ new_columns = []
34
+ for col in table.columns:
35
+ new_col = Column(
36
+ col.name,
37
+ col.type,
38
+ nullable=(True if col.name == column_name else col.nullable),
39
+ primary_key=col.primary_key,
40
+ default=col.default,
41
+ server_default=col.server_default
42
+ )
43
+ new_columns.append(new_col)
44
+
45
+ new_table = Table(f"{table_name}_new", MetaData(), *new_columns)
46
+ new_table.create(conn)
47
+
48
+ # Copy data
49
+ data = conn.execute(select(table)).mappings().all()
50
+ if data:
51
+ conn.execute(new_table.insert(), data)
52
+
53
+ # Drop old table, rename new table
54
+ table.drop(conn)
55
+ conn.execute(text(f"ALTER TABLE {table_name}_new RENAME TO {table_name}"))
56
+
57
+ class DatabaseManager:
58
+ """
59
+ Manages multiple database connections, engines, and session factories.
60
+ """
61
+
62
+ STORAGE_PATH = None
63
+ STORAGE_PATHS = None
64
+
65
+ @staticmethod
66
+ def _load_env_file():
67
+ """Load .env file from multiple possible locations (like worker_service.py)."""
68
+ if load_dotenv is None:
69
+ return
70
+ from os.path import join, dirname
71
+ import os
72
+ possible_paths = [
73
+ ".env",
74
+ join(dirname(__file__), '..', '..', '.env'),
75
+ join(dirname(__file__), '..', '.env'),
76
+ join(os.path.expanduser("~"), '.nedo_vision_worker.env'),
77
+ ]
78
+ for env_path in possible_paths:
79
+ if os.path.exists(env_path):
80
+ try:
81
+ load_dotenv(env_path)
82
+ logging.info(f"✅ [DB] Loaded environment from: {env_path}")
83
+ break
84
+ except Exception as e:
85
+ logging.warning(f"⚠️ [DB] Failed to load .env from {env_path}: {e}")
86
+
87
+ @staticmethod
88
+ def init_databases(storage_path: str = None):
89
+ """
90
+ Initialize databases and create tables for all models based on their `__bind_key__`.
91
+
92
+ Args:
93
+ storage_path: Storage path for databases and files. If None, will try to load from environment.
94
+ """
95
+ global _initialized
96
+ if _initialized:
97
+ logging.info("✅ Databases already initialized.")
98
+ return
99
+
100
+ # Set storage paths - prioritize parameter over environment variables
101
+ if storage_path:
102
+ DatabaseManager.STORAGE_PATH = Path(storage_path)
103
+ else:
104
+ # Fallback to environment variables for backward compatibility
105
+ DatabaseManager._load_env_file()
106
+ DatabaseManager.STORAGE_PATH = Path(os.environ.get("STORAGE_PATH", "data"))
107
+
108
+ DatabaseManager.STORAGE_PATHS = {
109
+ "db": DatabaseManager.STORAGE_PATH / "sqlite",
110
+ "files": DatabaseManager.STORAGE_PATH / "files",
111
+ "models": DatabaseManager.STORAGE_PATH / "model",
112
+ }
113
+
114
+ # Define database paths
115
+ DB_PATHS = {
116
+ "default": DatabaseManager.STORAGE_PATHS["db"] / "default.db",
117
+ "config": DatabaseManager.STORAGE_PATHS["db"] / "config.db",
118
+ "logging": DatabaseManager.STORAGE_PATHS["db"] / "logging.db",
119
+ }
120
+
121
+ # Initialize engines and session factories for each database
122
+ for name, path in DB_PATHS.items():
123
+ path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists
124
+ engine = create_engine(f"sqlite:///{path.as_posix()}")
125
+ ENGINES[name] = engine
126
+ SESSION_FACTORIES[name] = scoped_session(sessionmaker(bind=engine)) # Use scoped sessions
127
+ DatabaseManager.synchronize(name)
128
+
129
+ _initialized = True
130
+ logging.info("✅ [APP] Databases initialized successfully.")
131
+
132
+ @staticmethod
133
+ def synchronize(db_name):
134
+ engine = ENGINES[db_name]
135
+
136
+ models = []
137
+ for mapper_object in Base.registry.mappers:
138
+ model_class = mapper_object.class_
139
+ if hasattr(model_class, "__bind_key__") and model_class.__bind_key__ == db_name:
140
+ models.append(model_class)
141
+
142
+ inspector = inspect(engine)
143
+ dialect = inspector.dialect.name
144
+ existing_tables = inspector.get_table_names()
145
+
146
+ if not existing_tables:
147
+ if not models:
148
+ return
149
+
150
+ for model in models:
151
+ model.metadata.create_all(bind=engine)
152
+
153
+ logging.info(f"✅ [APP] Created initial tables for '{db_name}' database")
154
+ return
155
+
156
+ connection: Connection = engine.connect()
157
+ try:
158
+ with connection.begin():
159
+ context = MigrationContext.configure(connection)
160
+
161
+ target_metadata = MetaData()
162
+ for model in models:
163
+ for table in model.metadata.tables.values():
164
+ if table.name not in target_metadata.tables:
165
+ table.tometadata(target_metadata)
166
+
167
+ ops_list = produce_migrations(context, target_metadata).upgrade_ops_list
168
+
169
+ if ops_list[0].ops:
170
+ logging.info(f"⚡ [APP] Detected schema changes in '{db_name}' database. Applying migrations...")
171
+
172
+ op = Operations(context)
173
+ for upgrade_ops in ops_list:
174
+ for schema_element in upgrade_ops.ops:
175
+ if hasattr(schema_element, 'ops'):
176
+ for table_op in schema_element.ops:
177
+ if dialect == 'sqlite' and isinstance(table_op, AlterColumnOp):
178
+ alter_drop_not_null_sqlite(connection, table_op.table_name, table_op.column_name)
179
+ else:
180
+ op.invoke(table_op)
181
+ else:
182
+ if dialect == 'sqlite' and isinstance(schema_element, AlterColumnOp):
183
+ alter_drop_not_null_sqlite(connection, schema_element.table_name, schema_element.column_name)
184
+ else:
185
+ op.invoke(schema_element)
186
+
187
+ for diff in upgrade_ops.as_diffs():
188
+ if diff[0] == 'add_table':
189
+ table = diff[1]
190
+ logging.info(f"🆕 [APP] Creating new table: {table.name}")
191
+
192
+ elif diff[0] == 'remove_table':
193
+ table_name = diff[1].name
194
+ logging.info(f"🗑️ [APP] Removing table: {table_name}")
195
+
196
+ elif diff[0] == 'add_column':
197
+ table_name, column = diff[2], diff[3]
198
+ logging.info(f"➕ [APP] Adding column: {column.name} to table {table_name}")
199
+
200
+ elif diff[0] == 'remove_column':
201
+ table_name, column = diff[2], diff[3]
202
+ logging.info(f"➖ [APP] Removing column: {column.name} from table {table_name}")
203
+
204
+ logging.info(f"✅ [APP] Schema migrations for '{db_name}' database completed successfully")
205
+ else:
206
+ logging.info(f"✅ [APP] No schema changes detected for '{db_name}' database")
207
+
208
+ finally:
209
+ connection.close()
210
+
211
+ @staticmethod
212
+ def get_session(db_name: str = "default"):
213
+ """
214
+ Provide a session object for the specified database.
215
+
216
+ Args:
217
+ db_name (str): The name of the database connection.
218
+
219
+ Returns:
220
+ sqlalchemy.orm.Session: A session for the specified database.
221
+
222
+ Raises:
223
+ ValueError: If the database name is invalid.
224
+ """
225
+ if db_name not in SESSION_FACTORIES:
226
+ raise ValueError(f"❌ Invalid database name: {db_name}")
227
+ return SESSION_FACTORIES[db_name]
228
+
229
+ @staticmethod
230
+ def shutdown():
231
+ """
232
+ Cleanly dispose of all database connections.
233
+ """
234
+ for name, engine in ENGINES.items():
235
+ engine.dispose()
236
+ logging.info("🛑 All database connections have been closed.")
@@ -0,0 +1,22 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ class BaseDetector(ABC):
4
+ """
5
+ Abstract base class for all object detectors.
6
+ """
7
+
8
+ @abstractmethod
9
+ def load_model(self, model_metadata):
10
+ pass
11
+
12
+ @abstractmethod
13
+ def detect_objects(self, frame, confidence_threshold=0.7):
14
+ """
15
+ Detect objects in the input frame.
16
+ Args:
17
+ frame: Image/frame (numpy array)
18
+ confidence_threshold: Minimum confidence threshold for detections (optional)
19
+ Returns:
20
+ List of detections: [{"label": str, "confidence": float, "bbox": [x1, y1, x2, y2]}, ...]
21
+ """
22
+ pass