Compare commits
37 Commits
dan_branch
...
drho1y-mvp
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aa0c16359 | |||
| 3f8f3a40ec | |||
| e451b84450 | |||
| 988a9ebbf1 | |||
| 4aeec2bee7 | |||
| 3b9fc4c874 | |||
| 2831c396b9 | |||
| 31ca8bacd4 | |||
| ff51eccf22 | |||
| 50638a11be | |||
| 6b395e083e | |||
| 6f57372d30 | |||
| 2618bd9a7f | |||
| f38e3ff273 | |||
| 422d438b85 | |||
| a75d74cab4 | |||
| 256dfbe280 | |||
| 00d60d03fb | |||
| 0a0c5915ca | |||
| fd8be02416 | |||
| c62ebaae16 | |||
| 893f881419 | |||
| 023e699771 | |||
| b69fe70122 | |||
| 70bdfc1c5c | |||
| ff49b03be2 | |||
| 15645f7d25 | |||
| 2c4aae9523 | |||
| e546707069 | |||
| 5fa88eaa56 | |||
| 9c522fb95c | |||
| 77997736f1 | |||
| 8f43b9f455 | |||
| 6ffc2e606b | |||
| d09e1a58e5 | |||
| 93dce8e237 | |||
| e923afd4c7 |
2
3d_models/.gitignore
vendored
Normal file
2
3d_models/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.FCBak
|
||||
/export
|
||||
BIN
3d_models/conveer.FCStd
Normal file
BIN
3d_models/conveer.FCStd
Normal file
Binary file not shown.
49
README.md
49
README.md
@@ -0,0 +1,49 @@
|
||||
# Структура репозитория:
|
||||
|
||||
```
|
||||
├── arduino_code
|
||||
│ └── Test
|
||||
│ ├── include
|
||||
│ ├── lib
|
||||
│ ├── platformio.ini
|
||||
│ ├── src
|
||||
│ └── test
|
||||
├── backend_control
|
||||
│ ├── create_venv.sh
|
||||
│ ├── main.py
|
||||
│ └── testing
|
||||
│ ├── bin
|
||||
│ ├── include
|
||||
│ ├── lib
|
||||
│ ├── lib64 -> lib
|
||||
│ └── pyvenv.cfg
|
||||
├── kicad
|
||||
│ └── ozon
|
||||
│ ├── ozon.kicad_pcb
|
||||
│ ├── ozon.kicad_pro
|
||||
│ ├── ozon.kicad_sch
|
||||
│ └── ~ozon.kicad_sch.lck
|
||||
├── README.md
|
||||
└── specification
|
||||
├── mindmap.md
|
||||
├── mvp_1.drawio
|
||||
├── mvp_1.jpg
|
||||
├── mvp_1.md
|
||||
└── specification.md
|
||||
```
|
||||
|
||||
## Папка arduino_code
|
||||
|
||||
Тут находится код для контроллера
|
||||
|
||||
## Папка Backend_control
|
||||
|
||||
Тут находится простой python скрипт для управления шагововым двигателем по средствам последовательных команд.
|
||||
|
||||
## Папка kicad
|
||||
|
||||
Тут будут находится электрические схемы проекта
|
||||
|
||||
## Папка specification
|
||||
|
||||
В этой папке разрабатывается спецификация на проект
|
||||
5
arduino_code/Test/.gitignore
vendored
Normal file
5
arduino_code/Test/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
10
arduino_code/Test/.vscode/extensions.json
vendored
Normal file
10
arduino_code/Test/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"platformio.platformio-ide"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-vscode.cpptools-extension-pack"
|
||||
]
|
||||
}
|
||||
95
arduino_code/Test/README.md
Normal file
95
arduino_code/Test/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# 📡 Документация по MQTT интерфейсу (ESP32 + TMC2209 + Servo + VL53L0X)
|
||||
|
||||
## 📌 Общая информация
|
||||
- **Архитектура**: ESP32 (FreeRTOS задача `mqttTask`).
|
||||
- **Период опроса телеметрии**: 500 мс.
|
||||
- **Оптимизация трафика**:
|
||||
1. Публикация данных происходит **только при изменении значения** (строгое кэширование).
|
||||
2. Используется статический буфер (`intToString`/`uintToString`) вместо динамического класса `String` для экономии памяти и предотвращения фрагментации кучи.
|
||||
- **Префиксы**:
|
||||
- `.../control/...` — топики для **отправки команд** устройству (подписка).
|
||||
- `.../feedback/...` — топики для **получения статуса/телеметрии** от устройства (публикация).
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 1. Управление шаговым двигателем (Motor Control)
|
||||
|
||||
| Топик | Тип данных | Описание | Пример Payload |
|
||||
| :--- | :---: | :--- | :--- |
|
||||
| `motor/control/rpm` | Integer | Целевая скорость в об/мин (RPM). Отрицательные значения включают реверс. | `-150`, `0`, `300` |
|
||||
| `motor/control/driver` | String | Аппаратное вкл/выкл драйвера (пин `EN_PIN`). `on` = LOW (вкл), иначе = HIGH (выкл). | `on`, `off` |
|
||||
| `motor/control/totalsteps/reset`| Any | Сброс счетчика шагов в ноль. Устройство сразу опубликует `"0"` в feedback. | `1`, `reset` |
|
||||
|
||||
### Телеметрия двигателя (Motor Feedback)
|
||||
*(Публикуется только при изменении)*
|
||||
- `motor/feedback/rpm` (Integer): Текущая скорость.
|
||||
- `motor/feedback/totalsteps` (Integer): Общее количество шагов.
|
||||
- `motor/feedback/is_run` (String: `true`/`false`): Двигатель движется.
|
||||
- `motor/feedback/driver/status` (String: `on`/`off`): Общий статус драйвера.
|
||||
- `motor/feedback/tmc/status` (String: `on`/`off`): Статус программного включения TMC.
|
||||
|
||||
#### Детальная телеметрия TMC2209 (`motor/feedback/tmc/...`)
|
||||
- `current_percent` (Integer): Текущий % рабочего тока.
|
||||
- `microsteps` (Integer): Текущий режим микрошага.
|
||||
- `sg_result` (Integer): Текущее значение StallGuard (нагрузка).
|
||||
- `interstep_duration` (Integer): Длительность между шагами.
|
||||
- `status/over_temp` (String: `true`/`false`): Перегрев.
|
||||
- `status/short_to_ground` (String: `true`/`false`): КЗ на землю.
|
||||
- `status/open_load` (String: `true`/`false`): Обрыв нагрузки.
|
||||
- `status/stealth_chop_active` (String: `true`/`false`): Активен ли StealthChop.
|
||||
- `status/standstill` (String: `true`/`false`): Двигатель в покое.
|
||||
- `status/current_scaling` (Integer): Внутренний масштабный коэффициент тока.
|
||||
|
||||
---
|
||||
|
||||
## 🦾 2. Управление сервоприводами (Servo Control)
|
||||
|
||||
Поддержка нескольких каналов (`{channel}` от `0` до `MAX_SERVOS - 1`).
|
||||
|
||||
### Команды
|
||||
| Топик (пример для канала 0) | Тип данных | Описание | Пример Payload |
|
||||
| :--- | :---: | :--- | :--- |
|
||||
| `servo/control/0/angle` | Integer (0-180) | Установить угол поворота. | `90` |
|
||||
| `servo/control/0/enable` | String | Включить (`on`, `1`, `true`) или выключить. | `on` |
|
||||
|
||||
### Обратная связь
|
||||
- `servo/0/feedback/status` (String: `on`/`off`): Статус питания сервопривода.
|
||||
- `servo/0/feedback/angle` (Integer): Текущий установленный угол.
|
||||
|
||||
---
|
||||
|
||||
## 📏 3. Управление датчиками VL53L0X (Sensor Control) **(НОВОЕ)**
|
||||
|
||||
Поддержка до 8 каналов (`VL53L0X_MAX_CHANNELS = 8`). Топики используют параметр `{channel}` (0–7).
|
||||
|
||||
| Топик | Тип данных | Описание | Пример Payload |
|
||||
| :--- | :---: | :--- | :--- |
|
||||
| `sensor/control/mode` | Integer | Установка глобального режима измерения (0 до `MODE_COUNT - 1`). | `0`, `1` |
|
||||
| `sensor/control/mode_name` | String | *Заглушка/Логирование.* Принимает имя режима для отладки. | `LongRange` |
|
||||
| `sensor/control/calibrate/start/{ch}`| Integer | Начало калибровки: указать близкое расстояние в мм. | `50` |
|
||||
| `sensor/control/calibrate/finish/{ch}`| Integer | Завершение калибровки: указать дальнее расстояние в мм. | `500` |
|
||||
| `sensor/control/clear_cal/{ch}` | Any | Сбросить калибровку для указанного канала. | `1` |
|
||||
| `sensor/control/enable/{ch}` | String | Включить (`on`, `1`, `true`) или выключить конкретный канал. | `on` |
|
||||
| `sensor/control/publish_all` | Any | **Принудительный сброс кэша.** Заставляет устройство немедленно опубликовать текущие значения всех датчиков, даже если они не изменились. | `1` |
|
||||
|
||||
---
|
||||
|
||||
## 📊 4. Телеметрия датчиков VL53L0X (Sensor Feedback) **(НОВОЕ)**
|
||||
|
||||
### Глобальная информация о режиме
|
||||
Публикуется только при смене режима измерения:
|
||||
- `sensor/feedback/mode` (String): Человекочитаемое имя текущего режима (например, "Default", "LongRange").
|
||||
- `sensor/feedback/mode_id` (Integer): Числовой ID текущего режима.
|
||||
- `sensor/feedback/max_range` (Integer): Максимальная дальность для текущего режима (в мм).
|
||||
|
||||
### Постатусная информация по каналам (`{channel}` = 0..7)
|
||||
*(Публикуется только при изменении состояния или значения)*
|
||||
|
||||
| Топик (пример для канала 0) | Тип данных | Описание |
|
||||
| :--- | :---: | :--- |
|
||||
| `sensor/feedback/0/status` | String (`on`/`off`) | Включен ли логически данный канал. |
|
||||
| `sensor/feedback/0/calibrated` | String (`true`/`false`)| Была ли проведена калибровка для этого канала. |
|
||||
| `sensor/feedback/0/distance` | Integer или String | **Калиброванное** расстояние в мм. Если значение `65535` (ошибка/вне диапазона), публикуется строка `"out_of_range"`. |
|
||||
| `sensor/feedback/0/raw` | Integer | **Сырое** (некалиброванное) значение расстояния в мм. |
|
||||
|
||||
*Примечание: Топики `distance` и `raw` публикуются только если канал активен (`vl53l0xIsChannelActive`).*
|
||||
37
arduino_code/Test/include/README
Normal file
37
arduino_code/Test/include/README
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
This directory is intended for project header files.
|
||||
|
||||
A header file is a file containing C declarations and macro definitions
|
||||
to be shared between several project source files. You request the use of a
|
||||
header file in your project source file (C, C++, etc) located in `src` folder
|
||||
by including it, with the C preprocessing directive `#include'.
|
||||
|
||||
```src/main.c
|
||||
|
||||
#include "header.h"
|
||||
|
||||
int main (void)
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Including a header file produces the same results as copying the header file
|
||||
into each source file that needs it. Such copying would be time-consuming
|
||||
and error-prone. With a header file, the related declarations appear
|
||||
in only one place. If they need to be changed, they can be changed in one
|
||||
place, and programs that include the header file will automatically use the
|
||||
new version when next recompiled. The header file eliminates the labor of
|
||||
finding and changing all the copies as well as the risk that a failure to
|
||||
find one copy will result in inconsistencies within a program.
|
||||
|
||||
In C, the convention is to give header files names that end with `.h'.
|
||||
|
||||
Read more about using header files in official GCC documentation:
|
||||
|
||||
* Include Syntax
|
||||
* Include Operation
|
||||
* Once-Only Headers
|
||||
* Computed Includes
|
||||
|
||||
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||
46
arduino_code/Test/lib/README
Normal file
46
arduino_code/Test/lib/README
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
This directory is intended for project specific (private) libraries.
|
||||
PlatformIO will compile them to static libraries and link into the executable file.
|
||||
|
||||
The source code of each library should be placed in a separate directory
|
||||
("lib/your_library_name/[Code]").
|
||||
|
||||
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||
|
||||
|--lib
|
||||
| |
|
||||
| |--Bar
|
||||
| | |--docs
|
||||
| | |--examples
|
||||
| | |--src
|
||||
| | |- Bar.c
|
||||
| | |- Bar.h
|
||||
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||
| |
|
||||
| |--Foo
|
||||
| | |- Foo.c
|
||||
| | |- Foo.h
|
||||
| |
|
||||
| |- README --> THIS FILE
|
||||
|
|
||||
|- platformio.ini
|
||||
|--src
|
||||
|- main.c
|
||||
|
||||
Example contents of `src/main.c` using Foo and Bar:
|
||||
```
|
||||
#include <Foo.h>
|
||||
#include <Bar.h>
|
||||
|
||||
int main (void)
|
||||
{
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||
libraries by scanning project source files.
|
||||
|
||||
More information about PlatformIO Library Dependency Finder
|
||||
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||
28
arduino_code/Test/platformio.ini
Normal file
28
arduino_code/Test/platformio.ini
Normal file
@@ -0,0 +1,28 @@
|
||||
; PlatformIO Project Configuration File
|
||||
;
|
||||
; Build options: build flags, source filter
|
||||
; Upload options: custom upload port, speed and extra flags
|
||||
; Library options: dependencies, extra library storages
|
||||
; Advanced options: extra scripting
|
||||
;
|
||||
; Please visit documentation for the other options and examples
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[env:upesy_wroom]
|
||||
platform = espressif32
|
||||
board = upesy_wroom
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
upload_port = /dev/ttyUSB0
|
||||
lib_deps =
|
||||
knolleary/PubSubClient@^2.8
|
||||
janelia-arduino/TMC2209@^9.4.0
|
||||
madhephaestus/ESP32Servo@^3.2.1
|
||||
adafruit/Adafruit PWM Servo Driver Library@^3.0.3
|
||||
pololu/VL53L0X@^1.3.1
|
||||
build_flags =
|
||||
-Os
|
||||
-ffunction-sections
|
||||
-fdata-sections
|
||||
-Wl,--gc-sections
|
||||
38
arduino_code/Test/src/config.cpp
Normal file
38
arduino_code/Test/src/config.cpp
Normal file
@@ -0,0 +1,38 @@
|
||||
#include "config.h"
|
||||
|
||||
const char* WIFI_SSID = "Home";
|
||||
const char* WIFI_PASS = "88888888qwE";
|
||||
const char* MQTT_SERVER = "192.168.31.225";
|
||||
const int MQTT_PORT = 1883;
|
||||
const char* MQTT_USER = "test";
|
||||
const char* MQTT_PASS = "1234";
|
||||
const char* MQTT_CLIENT_ID = "ESP32_Stepper";
|
||||
|
||||
// UART2: RX=16, TX=17
|
||||
HardwareSerial& TMC_SERIAL = Serial2;
|
||||
const uint32_t TMC_BAUD_RATE = 115200;
|
||||
const uint8_t TMC_SERIAL_ADDRESS = 0; // Если MS1 и MS2 на GND
|
||||
|
||||
const int16_t TMC_RX_PIN = 16;
|
||||
const int16_t TMC_TX_PIN = 17;
|
||||
const int EN_PIN = 18;
|
||||
|
||||
const int STEPS_PER_REVOLUTION = 200;
|
||||
const unsigned long RAMP_DURATION_MS = 2000;
|
||||
|
||||
// Ток задается в процентах от максимума (зависит от R_sense).
|
||||
// Для R_sense=0.11 Ом, 100% ~ 1.77А RMS. Для R_sense=0.15 Ом, 100% ~ 1.2А RMS.
|
||||
const uint8_t TMC_RUN_CURRENT_PERCENT = 50; // 50% тока при движении
|
||||
const uint8_t TMC_HOLD_CURRENT_PERCENT = 20; // 20% тока в простое
|
||||
const uint8_t TMC_STALL_GUARD_THRESH = 10;
|
||||
const uint16_t TMC_MICROSTEPS = 1;
|
||||
|
||||
// I2C Настройка
|
||||
const int16_t I2C_SDA_PIN = 21;
|
||||
const int16_t I2C_SCL_PIN = 22;
|
||||
|
||||
|
||||
// ========== SERVO CONFIGURATION ==========
|
||||
#define SERVO_DEFAULT_CHANNEL 0
|
||||
#define SERVO_MIN_ANGLE 0
|
||||
#define SERVO_MAX_ANGLE 180
|
||||
38
arduino_code/Test/src/config.h
Normal file
38
arduino_code/Test/src/config.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#ifndef CONFIG_H
|
||||
#define CONFIG_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <Arduino.h>
|
||||
|
||||
// WiFi & MQTT
|
||||
extern const char* WIFI_SSID;
|
||||
extern const char* WIFI_PASS;
|
||||
extern const char* MQTT_SERVER;
|
||||
extern const int MQTT_PORT;
|
||||
extern const char* MQTT_USER;
|
||||
extern const char* MQTT_PASS;
|
||||
extern const char* MQTT_CLIENT_ID;
|
||||
|
||||
// UART for TMC2209
|
||||
extern HardwareSerial& TMC_SERIAL;
|
||||
extern const uint32_t TMC_BAUD_RATE;
|
||||
extern const uint8_t TMC_SERIAL_ADDRESS; // Адрес драйвера (0-3)
|
||||
extern const int16_t TMC_RX_PIN;
|
||||
extern const int16_t TMC_TX_PIN;
|
||||
extern const int EN_PIN;
|
||||
|
||||
// Motor Params
|
||||
extern const int STEPS_PER_REVOLUTION; // Базовые шаги мотора (обычно 200)
|
||||
extern const unsigned long RAMP_DURATION_MS;
|
||||
|
||||
// TMC2209 Defaults (Токи в процентах 0-100%)
|
||||
extern const uint8_t TMC_RUN_CURRENT_PERCENT;
|
||||
extern const uint8_t TMC_HOLD_CURRENT_PERCENT;
|
||||
extern const uint8_t TMC_STALL_GUARD_THRESH;
|
||||
extern const uint16_t TMC_MICROSTEPS;
|
||||
|
||||
// I2C Настройка
|
||||
extern const int16_t I2C_SDA_PIN;
|
||||
extern const int16_t I2C_SCL_PIN;
|
||||
|
||||
#endif
|
||||
42
arduino_code/Test/src/main.cpp
Normal file
42
arduino_code/Test/src/main.cpp
Normal file
@@ -0,0 +1,42 @@
|
||||
#include <Arduino.h>
|
||||
#include <TMC2209.h>
|
||||
#include "config.h"
|
||||
#include "motor.h"
|
||||
#include "vl53l0x_sensor.h"
|
||||
#include "mqtt_handler.h"
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
// Инициализация мотора (Core 1 context initially)
|
||||
motorInit();
|
||||
|
||||
xTaskCreatePinnedToCore(
|
||||
sensorTask,
|
||||
"SensorTask",
|
||||
4096,
|
||||
NULL,
|
||||
1,
|
||||
NULL,
|
||||
0
|
||||
);
|
||||
|
||||
// Запуск задачи MQTT на Core 0
|
||||
xTaskCreatePinnedToCore(
|
||||
mqttTask,
|
||||
"MQTT_Task",
|
||||
20480,
|
||||
NULL,
|
||||
3,
|
||||
NULL,
|
||||
0 // CORE 0
|
||||
);
|
||||
|
||||
Serial.println("System Initialized. Multi-core ready.");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Loop выполняется на Core 1
|
||||
motorLoop();
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
}
|
||||
295
arduino_code/Test/src/motor.cpp
Normal file
295
arduino_code/Test/src/motor.cpp
Normal file
@@ -0,0 +1,295 @@
|
||||
#include "motor.h"
|
||||
#include "config.h"
|
||||
|
||||
static TMC2209 stepper_driver;
|
||||
static bool tmc_initialized = false;
|
||||
static SemaphoreHandle_t tmc_uart_mutex = NULL;
|
||||
|
||||
volatile unsigned long total_steps = 0;
|
||||
static int target_rpm = 0;
|
||||
static int current_rpm_display = 0;
|
||||
|
||||
static bool is_ramping = false;
|
||||
static unsigned long ramp_start_ms = 0;
|
||||
static float start_speed_sps = 0;
|
||||
static float end_speed_sps = 0;
|
||||
static float current_speed_sps = 0;
|
||||
static int32_t last_vactual = 0;
|
||||
static bool velocity_sent = false; // Флаг для отправки хотя бы раз
|
||||
|
||||
static uint16_t current_microsteps = TMC_MICROSTEPS;
|
||||
static uint8_t current_run_percent = TMC_RUN_CURRENT_PERCENT;
|
||||
|
||||
#define TMC_LOCK() xSemaphoreTake(tmc_uart_mutex, portMAX_DELAY)
|
||||
#define TMC_UNLOCK() xSemaphoreGive(tmc_uart_mutex)
|
||||
|
||||
const float TMC_FCLK = 12800000.0;
|
||||
const float VACTUAL_FACTOR = 8388608.0 / TMC_FCLK;
|
||||
|
||||
int32_t calculateVActual(float microsteps_per_second) {
|
||||
return (int32_t)(microsteps_per_second * VACTUAL_FACTOR);
|
||||
}
|
||||
|
||||
void motorInit() {
|
||||
pinMode(EN_PIN, OUTPUT);
|
||||
digitalWrite(EN_PIN, LOW);
|
||||
tmc_uart_mutex = xSemaphoreCreateMutex();
|
||||
|
||||
TMC_SERIAL.begin(TMC_BAUD_RATE, SERIAL_8N1, TMC_RX_PIN, TMC_TX_PIN);
|
||||
delay(500);
|
||||
|
||||
TMC_LOCK();
|
||||
|
||||
stepper_driver.setup(TMC_SERIAL, TMC_BAUD_RATE,
|
||||
(TMC2209::SerialAddress)TMC_SERIAL_ADDRESS,
|
||||
TMC_RX_PIN, TMC_TX_PIN);
|
||||
|
||||
delay(200);
|
||||
|
||||
// Проверка связи
|
||||
if (!stepper_driver.isCommunicating()) {
|
||||
Serial.println("ERROR: TMC2209 not communicating!");
|
||||
TMC_UNLOCK();
|
||||
tmc_initialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println("TMC2209 communicating OK");
|
||||
|
||||
// Базовая настройка
|
||||
stepper_driver.setMicrostepsPerStep(TMC_MICROSTEPS);
|
||||
stepper_driver.setRunCurrent(TMC_RUN_CURRENT_PERCENT);
|
||||
stepper_driver.setHoldCurrent(TMC_HOLD_CURRENT_PERCENT);
|
||||
stepper_driver.setHoldDelay(7);
|
||||
stepper_driver.setStallGuardThreshold(TMC_STALL_GUARD_THRESH);
|
||||
|
||||
stepper_driver.enableAutomaticCurrentScaling();
|
||||
stepper_driver.enableAutomaticGradientAdaptation();
|
||||
|
||||
// КРИТИЧНО: Отключаем StealthChop для работы moveAtVelocity()!
|
||||
stepper_driver.disableStealthChop();
|
||||
delay(10);
|
||||
|
||||
// Включаем CoolStep для энергосбережения
|
||||
stepper_driver.enableCoolStep();
|
||||
|
||||
// Программное включение драйвера
|
||||
stepper_driver.enable();
|
||||
delay(100);
|
||||
|
||||
tmc_initialized = stepper_driver.isSetupAndCommunicating();
|
||||
|
||||
if (tmc_initialized) {
|
||||
Serial.println("TMC2209 initialized successfully");
|
||||
TMC2209::Settings settings = stepper_driver.getSettings();
|
||||
Serial.printf("Run: %d%%, Hold: %d%%, Microsteps: %d, StealthChop: %s\n",
|
||||
settings.irun_percent, settings.ihold_percent,
|
||||
settings.microsteps_per_step, settings.stealth_chop_enabled ? "ON" : "OFF");
|
||||
} else {
|
||||
Serial.println("ERROR: TMC2209 setup failed!");
|
||||
}
|
||||
|
||||
TMC_UNLOCK();
|
||||
|
||||
current_microsteps = TMC_MICROSTEPS;
|
||||
current_run_percent = TMC_RUN_CURRENT_PERCENT;
|
||||
}
|
||||
|
||||
void motorLoop() {
|
||||
if (is_ramping) {
|
||||
unsigned long now = millis();
|
||||
unsigned long elapsed = now - ramp_start_ms;
|
||||
|
||||
if (elapsed >= RAMP_DURATION_MS) {
|
||||
is_ramping = false;
|
||||
current_speed_sps = end_speed_sps;
|
||||
} else {
|
||||
float progress = (float)elapsed / RAMP_DURATION_MS;
|
||||
current_speed_sps = start_speed_sps + (end_speed_sps - start_speed_sps) * progress;
|
||||
}
|
||||
|
||||
int32_t vactual = calculateVActual(current_speed_sps);
|
||||
|
||||
// Отправляем если: скорость изменилась ИЛИ это первая отправка в рампе
|
||||
if (abs(vactual - last_vactual) >= 0 || !velocity_sent) {
|
||||
TMC_LOCK();
|
||||
stepper_driver.moveAtVelocity(vactual);
|
||||
TMC_UNLOCK();
|
||||
last_vactual = vactual;
|
||||
velocity_sent = true;
|
||||
|
||||
Serial.printf("VACTUAL: %d (SPS: %.1f, RPM: %d)\n",
|
||||
vactual, current_speed_sps, current_rpm_display);
|
||||
}
|
||||
|
||||
unsigned long steps_per_rev = (unsigned long)STEPS_PER_REVOLUTION * current_microsteps;
|
||||
current_rpm_display = ((unsigned long)abs(current_speed_sps) * 60) / steps_per_rev;
|
||||
} else if (!velocity_sent && current_speed_sps == 0) {
|
||||
// Если мотор стоит и скорость не отправлялась - отправляем 0
|
||||
TMC_LOCK();
|
||||
stepper_driver.moveAtVelocity(0);
|
||||
TMC_UNLOCK();
|
||||
velocity_sent = true;
|
||||
}
|
||||
if (current_speed_sps != 0) {
|
||||
total_steps += (unsigned long)(abs(current_speed_sps) * 0.005);
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
}
|
||||
|
||||
void setTargetRPM(int rpm) {
|
||||
target_rpm = rpm;
|
||||
if (rpm != 0 && current_rpm_display < 5) resetSteps();
|
||||
|
||||
unsigned long steps_per_rev = (unsigned long)STEPS_PER_REVOLUTION * current_microsteps;
|
||||
|
||||
start_speed_sps = current_speed_sps;
|
||||
end_speed_sps = (rpm != 0) ? ((float)rpm * steps_per_rev) / 60.0f : 0;
|
||||
|
||||
ramp_start_ms = millis();
|
||||
is_ramping = true;
|
||||
velocity_sent = false; // Сбрасываем флаг для новой отправки
|
||||
|
||||
Serial.printf("Target RPM: %d -> SPS: %.1f\n", rpm, end_speed_sps);
|
||||
}
|
||||
|
||||
void resetSteps() {
|
||||
total_steps = 0;
|
||||
}
|
||||
|
||||
unsigned long getMotorSteps() { return total_steps; }
|
||||
int getCurrentRPM() { return current_rpm_display; }
|
||||
bool isMotorRunning() { return (abs(current_speed_sps) > 1); }
|
||||
|
||||
void tmcSetCurrentPercent(uint8_t run_percent, uint8_t hold_percent) {
|
||||
if (!tmc_initialized) return;
|
||||
TMC_LOCK();
|
||||
stepper_driver.setAllCurrentValues(run_percent, hold_percent, 7);
|
||||
TMC_UNLOCK();
|
||||
current_run_percent = run_percent;
|
||||
}
|
||||
|
||||
void tmcSetMicrosteps(uint16_t ms) {
|
||||
if (!tmc_initialized) return;
|
||||
TMC_LOCK();
|
||||
stepper_driver.setMicrostepsPerStep(ms);
|
||||
TMC_UNLOCK();
|
||||
current_microsteps = ms;
|
||||
if (target_rpm != 0) setTargetRPM(target_rpm);
|
||||
}
|
||||
|
||||
void tmcSetStallGuard(uint8_t threshold) {
|
||||
if (!tmc_initialized) return;
|
||||
TMC_LOCK();
|
||||
stepper_driver.setStallGuardThreshold(threshold);
|
||||
TMC_UNLOCK();
|
||||
}
|
||||
|
||||
void tmcSoftwareEnable(bool enable) {
|
||||
if (!tmc_initialized) return;
|
||||
TMC_LOCK();
|
||||
if (enable) {
|
||||
stepper_driver.enable();
|
||||
// После enable нужно заново отправить скорость
|
||||
velocity_sent = false;
|
||||
} else {
|
||||
stepper_driver.moveAtVelocity(0);
|
||||
stepper_driver.disable();
|
||||
last_vactual = 0;
|
||||
current_speed_sps = 0;
|
||||
is_ramping = false;
|
||||
}
|
||||
TMC_UNLOCK();
|
||||
}
|
||||
|
||||
void tmcSetStealthChop(bool enable) {
|
||||
if (!tmc_initialized) return;
|
||||
TMC_LOCK();
|
||||
if (enable) {
|
||||
stepper_driver.enableStealthChop();
|
||||
// В StealthChop VACTUAL не работает, останавливаем мотор
|
||||
stepper_driver.moveAtVelocity(0);
|
||||
last_vactual = 0;
|
||||
current_speed_sps = 0;
|
||||
} else {
|
||||
stepper_driver.disableStealthChop();
|
||||
velocity_sent = false;
|
||||
}
|
||||
TMC_UNLOCK();
|
||||
}
|
||||
|
||||
void tmcSetCoolStep(bool enable) {
|
||||
if (!tmc_initialized) return;
|
||||
TMC_LOCK();
|
||||
enable ? stepper_driver.enableCoolStep() : stepper_driver.disableCoolStep();
|
||||
TMC_UNLOCK();
|
||||
}
|
||||
|
||||
bool tmcIsInitialized() { return tmc_initialized; }
|
||||
|
||||
bool tmcIsCommunicating() {
|
||||
if (!tmc_initialized) return false;
|
||||
TMC_LOCK();
|
||||
bool res = stepper_driver.isCommunicating();
|
||||
TMC_UNLOCK();
|
||||
return res;
|
||||
}
|
||||
|
||||
// Функция проверки состояния EN_PIN
|
||||
bool checkDriverStatus() {
|
||||
bool en_state = (digitalRead(EN_PIN) == LOW);
|
||||
return en_state;
|
||||
}
|
||||
|
||||
// Функция проверки программного состояния TMC
|
||||
bool checkTmcSoftwareEnable() {
|
||||
TMC2209::Settings s = tmcGetSettings();
|
||||
bool tmc_state = s.software_enabled;
|
||||
return tmc_state;
|
||||
}
|
||||
|
||||
uint16_t tmcGetMicrostepsSetting() { return current_microsteps; }
|
||||
uint8_t tmcGetRunCurrentPercent() { return current_run_percent; }
|
||||
|
||||
TMC2209::Status tmcGetStatus() {
|
||||
TMC2209::Status s = {};
|
||||
if (!tmc_initialized) return s;
|
||||
TMC_LOCK();
|
||||
s = stepper_driver.getStatus();
|
||||
TMC_UNLOCK();
|
||||
return s;
|
||||
}
|
||||
|
||||
TMC2209::Settings tmcGetSettings() {
|
||||
TMC2209::Settings s = {};
|
||||
if (!tmc_initialized) return s;
|
||||
TMC_LOCK();
|
||||
s = stepper_driver.getSettings();
|
||||
TMC_UNLOCK();
|
||||
return s;
|
||||
}
|
||||
|
||||
uint16_t tmcGetStallGuardResult() {
|
||||
if (!tmc_initialized) return 0;
|
||||
TMC_LOCK();
|
||||
uint16_t res = stepper_driver.getStallGuardResult();
|
||||
TMC_UNLOCK();
|
||||
return res;
|
||||
}
|
||||
|
||||
uint32_t tmcGetInterstepDuration() {
|
||||
if (!tmc_initialized) return 0;
|
||||
TMC_LOCK();
|
||||
uint32_t res = stepper_driver.getInterstepDuration();
|
||||
TMC_UNLOCK();
|
||||
return res;
|
||||
}
|
||||
|
||||
uint16_t tmcGetMicrostepCounter() {
|
||||
if (!tmc_initialized) return 0;
|
||||
TMC_LOCK();
|
||||
uint16_t res = stepper_driver.getMicrostepCounter();
|
||||
TMC_UNLOCK();
|
||||
return res;
|
||||
}
|
||||
42
arduino_code/Test/src/motor.h
Normal file
42
arduino_code/Test/src/motor.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#ifndef MOTOR_H
|
||||
#define MOTOR_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <TMC2209.h>
|
||||
|
||||
void motorInit();
|
||||
void motorLoop();
|
||||
|
||||
// Управление движением (через UART VACTUAL)
|
||||
void setTargetRPM(int rpm); // Поддерживает отрицательные значения для реверса!
|
||||
void resetSteps();
|
||||
|
||||
// Геттеры состояния движения
|
||||
unsigned long getMotorSteps(); // Считается программно
|
||||
int getCurrentRPM();
|
||||
bool isMotorRunning();
|
||||
|
||||
// Управление TMC2209 через UART
|
||||
void tmcSetCurrentPercent(uint8_t run_percent, uint8_t hold_percent);
|
||||
void tmcSetMicrosteps(uint16_t ms);
|
||||
void tmcSetStallGuard(uint8_t threshold);
|
||||
void tmcSoftwareEnable(bool enable); // Вкл/Выкл драйвер программно
|
||||
void tmcSetStealthChop(bool enable);
|
||||
void tmcSetCoolStep(bool enable);
|
||||
|
||||
// Расширенная телеметрия
|
||||
bool tmcIsInitialized();
|
||||
bool tmcIsCommunicating();
|
||||
uint16_t tmcGetMicrostepsSetting();
|
||||
uint8_t tmcGetRunCurrentPercent();
|
||||
|
||||
// Структуры статусов для MQTT
|
||||
TMC2209::Status tmcGetStatus();
|
||||
TMC2209::Settings tmcGetSettings();
|
||||
uint16_t tmcGetStallGuardResult();
|
||||
uint32_t tmcGetInterstepDuration();
|
||||
uint16_t tmcGetMicrostepCounter();
|
||||
bool checkDriverStatus();
|
||||
bool checkTmcSoftwareEnable();
|
||||
|
||||
#endif
|
||||
506
arduino_code/Test/src/mqtt_handler.cpp
Normal file
506
arduino_code/Test/src/mqtt_handler.cpp
Normal file
@@ -0,0 +1,506 @@
|
||||
#include "mqtt_handler.h"
|
||||
#include "config.h"
|
||||
#include "motor.h"
|
||||
#include "servo_control.h"
|
||||
#include "vl53l0x_sensor.h"
|
||||
|
||||
static WiFiClient espClient;
|
||||
static PubSubClient client(espClient);
|
||||
|
||||
// Кэш телеметрии
|
||||
static unsigned long last_feedback_time = 0;
|
||||
static int last_pub_rpm = -1;
|
||||
static unsigned long last_pub_steps = -1;
|
||||
static int last_pub_is_run = -1;
|
||||
|
||||
// TMC Кэш
|
||||
static uint16_t last_pub_sg = 65535;
|
||||
static uint32_t last_pub_interstep = 0;
|
||||
static uint8_t last_pub_current_pct = 255;
|
||||
static uint16_t last_pub_microsteps = 0;
|
||||
|
||||
// Статусы (битовые флаги)
|
||||
static int last_pub_over_temp = -1;
|
||||
static int last_pub_short_gnd = -1;
|
||||
static int last_pub_open_load = -1;
|
||||
static int last_pub_stealth_active = -1;
|
||||
static int last_pub_standstill = -1;
|
||||
static int last_pub_driver_status = -1;
|
||||
static int last_pub_tmc_software_enable = -1;
|
||||
static uint8_t last_pub_current_scaling = 255;
|
||||
|
||||
// ============================================
|
||||
// КЭШИРОВАНИЕ ДЛЯ VL53L0X
|
||||
// ============================================
|
||||
|
||||
#define VL53L0X_MAX_CHANNELS 8
|
||||
static uint16_t last_pub_vl53_distance[VL53L0X_MAX_CHANNELS] = {65535};
|
||||
static uint16_t last_pub_vl53_raw[VL53L0X_MAX_CHANNELS] = {65535};
|
||||
static int last_pub_vl53_status[VL53L0X_MAX_CHANNELS] = {-1};
|
||||
static int last_pub_vl53_calibrated[VL53L0X_MAX_CHANNELS] = {-1};
|
||||
static MeasurementMode last_pub_vl53_mode = MODE_COUNT;
|
||||
|
||||
// ============================================
|
||||
// БУФЕРЫ ДЛЯ ПРЕОБРАЗОВАНИЯ
|
||||
// ============================================
|
||||
|
||||
static char int_buffer[16];
|
||||
static char uint_buffer[16];
|
||||
|
||||
static const char* intToString(int value) {
|
||||
snprintf(int_buffer, sizeof(int_buffer), "%d", value);
|
||||
return int_buffer;
|
||||
}
|
||||
|
||||
static const char* uintToString(unsigned long value) {
|
||||
snprintf(uint_buffer, sizeof(uint_buffer), "%lu", value);
|
||||
return uint_buffer;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WIFI И MQTT ПОДКЛЮЧЕНИЕ
|
||||
// ============================================
|
||||
|
||||
static void setup_wifi() {
|
||||
Serial.print("Connecting to WiFi");
|
||||
WiFi.begin(WIFI_SSID, WIFI_PASS);
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
Serial.print(".");
|
||||
}
|
||||
Serial.println("\nWiFi Connected");
|
||||
}
|
||||
|
||||
static void resetAllCaches() {
|
||||
// Motor
|
||||
last_pub_rpm = -1;
|
||||
last_pub_steps = (unsigned long)-1;
|
||||
last_pub_is_run = -1;
|
||||
last_pub_sg = 65535;
|
||||
last_pub_interstep = 0;
|
||||
last_pub_current_pct = 255;
|
||||
last_pub_microsteps = 0;
|
||||
last_pub_over_temp = -1;
|
||||
last_pub_short_gnd = -1;
|
||||
last_pub_open_load = -1;
|
||||
last_pub_stealth_active = -1;
|
||||
last_pub_standstill = -1;
|
||||
last_pub_current_scaling = 255;
|
||||
last_pub_driver_status = -1;
|
||||
last_pub_tmc_software_enable = -1;
|
||||
|
||||
// VL53L0X
|
||||
for (int i = 0; i < VL53L0X_MAX_CHANNELS; i++) {
|
||||
last_pub_vl53_distance[i] = 65535;
|
||||
last_pub_vl53_raw[i] = 65535;
|
||||
last_pub_vl53_status[i] = -1;
|
||||
last_pub_vl53_calibrated[i] = -1;
|
||||
}
|
||||
last_pub_vl53_mode = MODE_COUNT;
|
||||
}
|
||||
|
||||
static void subscribeToAllTopics() {
|
||||
// Motor
|
||||
client.subscribe("motor/control/rpm");
|
||||
client.subscribe("motor/control/driver");
|
||||
client.subscribe("motor/control/totalsteps/reset");
|
||||
client.subscribe("motor/control/tmc/current_percent");
|
||||
client.subscribe("motor/control/tmc/microsteps");
|
||||
client.subscribe("motor/control/tmc/stallguard");
|
||||
client.subscribe("motor/control/tmc/enable");
|
||||
client.subscribe("motor/control/tmc/stealthchop");
|
||||
client.subscribe("motor/control/tmc/coolstep");
|
||||
|
||||
// Servo
|
||||
client.subscribe("servo/control/+/#");
|
||||
|
||||
// VL53L0X
|
||||
client.subscribe("sensor/control/mode");
|
||||
client.subscribe("sensor/control/mode_name");
|
||||
client.subscribe("sensor/control/calibrate/start/+");
|
||||
client.subscribe("sensor/control/calibrate/finish/+");
|
||||
client.subscribe("sensor/control/clear_cal/+");
|
||||
client.subscribe("sensor/control/enable/+");
|
||||
client.subscribe("sensor/control/publish_all");
|
||||
}
|
||||
|
||||
static void reconnect() {
|
||||
while (!client.connected()) {
|
||||
if (client.connect(MQTT_CLIENT_ID, MQTT_USER, MQTT_PASS)) {
|
||||
resetAllCaches();
|
||||
subscribeToAllTopics();
|
||||
Serial.println("MQTT Connected and subscribed");
|
||||
} else {
|
||||
Serial.printf("MQTT connection failed, rc=%d, retrying...\n", client.state());
|
||||
vTaskDelay(pdMS_TO_TICKS(5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ОБРАБОТКА ВХОДЯЩИХ MQTT КОМАНД
|
||||
// ============================================
|
||||
static void callback(char* topic, byte* payload, unsigned int length) {
|
||||
char msg[length + 1];
|
||||
memcpy(msg, payload, length);
|
||||
msg[length] = '\0';
|
||||
|
||||
if (strcmp(topic, "motor/control/rpm") == 0) {
|
||||
setTargetRPM(atoi(msg)); // Поддерживает отрицательные для реверса!
|
||||
}
|
||||
else if (strcmp(topic, "motor/control/driver") == 0) {
|
||||
// TMC2209: LOW = Enabled, HIGH = Disabled
|
||||
bool enable = (strcmp(msg, "on") == 0);
|
||||
digitalWrite(EN_PIN, enable ? LOW : HIGH);
|
||||
}
|
||||
else if (strcmp(topic, "motor/control/totalsteps/reset") == 0) {
|
||||
resetSteps();
|
||||
if (client.connected()) client.publish("motor/feedback/totalsteps", "0");
|
||||
last_pub_steps = 0;
|
||||
}
|
||||
// --- TMC Control ---
|
||||
else if (strcmp(topic, "motor/control/tmc/current_percent") == 0) {
|
||||
uint8_t pct = atoi(msg);
|
||||
if (pct <= 100) tmcSetCurrentPercent(pct, pct / 2); // Hold = 50% от Run
|
||||
}
|
||||
else if (strcmp(topic, "motor/control/tmc/microsteps") == 0) {
|
||||
uint16_t ms = atoi(msg);
|
||||
tmcSetMicrosteps(ms);
|
||||
}
|
||||
else if (strcmp(topic, "motor/control/tmc/stallguard") == 0) {
|
||||
tmcSetStallGuard(atoi(msg));
|
||||
}
|
||||
else if (strcmp(topic, "motor/control/tmc/enable") == 0) {
|
||||
tmcSoftwareEnable(strcmp(msg, "on") == 0);
|
||||
}
|
||||
else if (strcmp(topic, "motor/control/tmc/stealthchop") == 0) {
|
||||
tmcSetStealthChop(strcmp(msg, "on") == 0);
|
||||
}
|
||||
else if (strcmp(topic, "motor/control/tmc/coolstep") == 0) {
|
||||
tmcSetCoolStep(strcmp(msg, "on") == 0);
|
||||
}
|
||||
else if (strncmp(topic, "servo/control", 12) == 0) {
|
||||
handleServoMQTTCommand(topic, msg);
|
||||
}
|
||||
// === VL53L0X ===
|
||||
else if (strcmp(topic, "sensor/control/mode") == 0) {
|
||||
int mode = atoi(msg);
|
||||
if (mode >= 0 && mode < MODE_COUNT) {
|
||||
vl53l0xSetMode((MeasurementMode)mode);
|
||||
}
|
||||
}
|
||||
else if (strcmp(topic, "sensor/control/mode_name") == 0) {
|
||||
// Маппинг имени режима на ID (если нужно)
|
||||
// Пока просто логируем
|
||||
Serial.printf("Mode name request: %s\n", msg);
|
||||
}
|
||||
else if (strncmp(topic, "sensor/control/calibrate/start/", 31) == 0) {
|
||||
int channel = atoi(topic + 31);
|
||||
int near_mm = atoi(msg);
|
||||
if (channel >= 0 && channel < VL53L0X_MAX_CHANNELS && near_mm > 0) {
|
||||
vl53l0xStartCalibration(channel, near_mm);
|
||||
}
|
||||
}
|
||||
else if (strncmp(topic, "sensor/control/calibrate/finish/", 32) == 0) {
|
||||
int channel = atoi(topic + 32);
|
||||
int far_mm = atoi(msg);
|
||||
if (channel >= 0 && channel < VL53L0X_MAX_CHANNELS && far_mm > 0) {
|
||||
vl53l0xFinishCalibration(channel, far_mm);
|
||||
}
|
||||
}
|
||||
else if (strncmp(topic, "sensor/control/clear_cal/", 25) == 0) {
|
||||
int channel = atoi(topic + 25);
|
||||
if (channel >= 0 && channel < VL53L0X_MAX_CHANNELS) {
|
||||
vl53l0xClearCalibration(channel);
|
||||
}
|
||||
}
|
||||
else if (strncmp(topic, "sensor/control/enable/", 22) == 0) {
|
||||
int channel = atoi(topic + 22);
|
||||
bool enable = (strcmp(msg, "on") == 0 || strcmp(msg, "1") == 0 || strcmp(msg, "true") == 0);
|
||||
if (channel >= 0 && channel < VL53L0X_MAX_CHANNELS) {
|
||||
vl53l0xEnableChannel(channel, enable);
|
||||
}
|
||||
}
|
||||
else if (strcmp(topic, "sensor/control/publish_all") == 0) {
|
||||
// Сбрасываем кэш для принудительной публикации
|
||||
for (int i = 0; i < VL53L0X_MAX_CHANNELS; i++) {
|
||||
last_pub_vl53_distance[i] = 65535;
|
||||
last_pub_vl53_raw[i] = 65535;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ПУБЛИКАЦИЯ MOTOR TELEMETRY
|
||||
// ============================================
|
||||
|
||||
static void publishMotorTelemetry() {
|
||||
// RPM
|
||||
int rpm = getCurrentRPM();
|
||||
if (rpm != last_pub_rpm) {
|
||||
client.publish("motor/feedback/rpm", intToString(rpm));
|
||||
last_pub_rpm = rpm;
|
||||
}
|
||||
|
||||
// Steps
|
||||
unsigned long steps = getMotorSteps();
|
||||
if (steps != last_pub_steps) {
|
||||
client.publish("motor/feedback/totalsteps", uintToString(steps));
|
||||
last_pub_steps = steps;
|
||||
}
|
||||
|
||||
// Is running
|
||||
int run = isMotorRunning() ? 1 : 0;
|
||||
if (run != last_pub_is_run) {
|
||||
client.publish("motor/feedback/is_run", run ? "true" : "false");
|
||||
last_pub_is_run = run;
|
||||
}
|
||||
|
||||
// TMC
|
||||
if (tmcIsInitialized()) {
|
||||
uint8_t pct = tmcGetRunCurrentPercent();
|
||||
if (pct != last_pub_current_pct) {
|
||||
client.publish("motor/feedback/tmc/current_percent", intToString(pct));
|
||||
last_pub_current_pct = pct;
|
||||
}
|
||||
|
||||
uint16_t ms = tmcGetMicrostepsSetting();
|
||||
if (ms != last_pub_microsteps) {
|
||||
client.publish("motor/feedback/tmc/microsteps", intToString(ms));
|
||||
last_pub_microsteps = ms;
|
||||
}
|
||||
|
||||
uint16_t sg = tmcGetStallGuardResult();
|
||||
if (sg != last_pub_sg) {
|
||||
client.publish("motor/feedback/tmc/sg_result", intToString(sg));
|
||||
last_pub_sg = sg;
|
||||
}
|
||||
|
||||
uint32_t interstep = tmcGetInterstepDuration();
|
||||
if (interstep != last_pub_interstep) {
|
||||
client.publish("motor/feedback/tmc/interstep_duration", uintToString(interstep));
|
||||
last_pub_interstep = interstep;
|
||||
}
|
||||
|
||||
TMC2209::Status status = tmcGetStatus();
|
||||
|
||||
int ot = (status.over_temperature_warning || status.over_temperature_shutdown) ? 1 : 0;
|
||||
if (ot != last_pub_over_temp) {
|
||||
client.publish("motor/feedback/tmc/status/over_temp", ot ? "true" : "false");
|
||||
last_pub_over_temp = ot;
|
||||
}
|
||||
|
||||
int sgnd = (status.short_to_ground_a || status.short_to_ground_b) ? 1 : 0;
|
||||
if (sgnd != last_pub_short_gnd) {
|
||||
client.publish("motor/feedback/tmc/status/short_to_ground", sgnd ? "true" : "false");
|
||||
last_pub_short_gnd = sgnd;
|
||||
}
|
||||
|
||||
int ol = (status.open_load_a || status.open_load_b) ? 1 : 0;
|
||||
if (ol != last_pub_open_load) {
|
||||
client.publish("motor/feedback/tmc/status/open_load", ol ? "true" : "false");
|
||||
last_pub_open_load = ol;
|
||||
}
|
||||
|
||||
int sa = status.stealth_chop_mode ? 1 : 0;
|
||||
if (sa != last_pub_stealth_active) {
|
||||
client.publish("motor/feedback/tmc/status/stealth_chop_active", sa ? "true" : "false");
|
||||
last_pub_stealth_active = sa;
|
||||
}
|
||||
|
||||
int ss = status.standstill ? 1 : 0;
|
||||
if (ss != last_pub_standstill) {
|
||||
client.publish("motor/feedback/tmc/status/standstill", ss ? "true" : "false");
|
||||
last_pub_standstill = ss;
|
||||
}
|
||||
|
||||
if (status.current_scaling != last_pub_current_scaling) {
|
||||
client.publish("motor/feedback/tmc/status/current_scaling", intToString(status.current_scaling));
|
||||
last_pub_current_scaling = status.current_scaling;
|
||||
}
|
||||
|
||||
int cds = checkDriverStatus() ? 1 : 0;
|
||||
if (cds != last_pub_driver_status) {
|
||||
client.publish("motor/feedback/driver/status", cds ? "on" : "off");
|
||||
last_pub_driver_status = cds;
|
||||
}
|
||||
|
||||
int tse = checkTmcSoftwareEnable() ? 1 : 0;
|
||||
if (tse != last_pub_tmc_software_enable) {
|
||||
client.publish("motor/feedback/tmc/status", tse ? "on" : "off");
|
||||
last_pub_tmc_software_enable = tse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ПУБЛИКАЦИЯ VL53L0X TELEMETRY
|
||||
// ============================================
|
||||
|
||||
static void publishVL53L0XTelemetry() {
|
||||
// Публикация режима
|
||||
MeasurementMode current_mode = vl53l0xGetMode();
|
||||
if (current_mode != last_pub_vl53_mode) {
|
||||
const ModeProfile* profile = vl53l0xGetModeProfile();
|
||||
|
||||
client.publish("sensor/feedback/mode", profile->name);
|
||||
client.publish("sensor/feedback/mode_id", intToString(current_mode));
|
||||
client.publish("sensor/feedback/max_range", intToString(profile->max_range_mm));
|
||||
|
||||
last_pub_vl53_mode = current_mode;
|
||||
}
|
||||
|
||||
// Публикация данных с каждого канала
|
||||
for (uint8_t ch = 0; ch < VL53L0X_MAX_CHANNELS; ch++) {
|
||||
if (!vl53l0xIsChannelPresent(ch)) continue;
|
||||
|
||||
char topic[64];
|
||||
|
||||
// Статус канала
|
||||
int status = vl53l0xIsChannelEnabled(ch) ? 1 : 0;
|
||||
if (status != last_pub_vl53_status[ch]) {
|
||||
snprintf(topic, sizeof(topic), "sensor/feedback/%d/status", ch);
|
||||
client.publish(topic, status ? "on" : "off");
|
||||
last_pub_vl53_status[ch] = status;
|
||||
}
|
||||
|
||||
// Статус калибровки
|
||||
int calibrated = vl53l0xIsCalibrated(ch) ? 1 : 0;
|
||||
if (calibrated != last_pub_vl53_calibrated[ch]) {
|
||||
snprintf(topic, sizeof(topic), "sensor/feedback/%d/calibrated", ch);
|
||||
client.publish(topic, calibrated ? "true" : "false");
|
||||
last_pub_vl53_calibrated[ch] = calibrated;
|
||||
}
|
||||
|
||||
// Только если канал активен, публикуем расстояния
|
||||
if (vl53l0xIsChannelActive(ch)) {
|
||||
// Калиброванное расстояние
|
||||
uint16_t distance = vl53l0xReadDistance(ch);
|
||||
if (distance != last_pub_vl53_distance[ch]) {
|
||||
snprintf(topic, sizeof(topic), "sensor/feedback/%d/distance", ch);
|
||||
if (distance != 65535) {
|
||||
client.publish(topic, intToString(distance));
|
||||
} else {
|
||||
client.publish(topic, "out_of_range");
|
||||
}
|
||||
last_pub_vl53_distance[ch] = distance;
|
||||
}
|
||||
|
||||
// Сырое значение
|
||||
uint16_t raw = vl53l0xReadRawDistance(ch);
|
||||
if (raw != last_pub_vl53_raw[ch]) {
|
||||
snprintf(topic, sizeof(topic), "sensor/feedback/%d/raw", ch);
|
||||
if (raw != 65535) {
|
||||
client.publish(topic, intToString(raw));
|
||||
}
|
||||
last_pub_vl53_raw[ch] = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////
|
||||
|
||||
void checkAndPublishServoStatus(uint8_t channel) {
|
||||
if (channel >= MAX_SERVOS) return;
|
||||
|
||||
bool current_state = servo_enabled[channel];
|
||||
if (current_state != last_published_status[channel]) {
|
||||
last_published_status[channel] = current_state;
|
||||
|
||||
char topic[64];
|
||||
snprintf(topic, sizeof(topic), "servo/%d/feedback/status", channel);
|
||||
|
||||
String status = current_state ? "on" : "off";
|
||||
client.publish(topic, status.c_str());
|
||||
|
||||
Serial.printf("Published servo %d status: %s\n", channel, status.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void checkAndPublishServoAngle(uint8_t channel) {
|
||||
if (channel >= MAX_SERVOS) return;
|
||||
|
||||
uint8_t current_angle = current_angles[channel];
|
||||
if (current_angle != last_published_angles[channel]) {
|
||||
last_published_angles[channel] = current_angle;
|
||||
|
||||
char topic[64];
|
||||
snprintf(topic, sizeof(topic), "servo/%d/feedback/angle", channel);
|
||||
|
||||
char payload[8];
|
||||
snprintf(payload, sizeof(payload), "%d", current_angle);
|
||||
client.publish(topic, payload);
|
||||
|
||||
Serial.printf("Published servo %d angle: %d\n", channel, current_angle);
|
||||
}
|
||||
}
|
||||
|
||||
void handleServoMQTTCommand(const char* topic, const char* payload) {
|
||||
// Парсим топик: servo/control/{channel}/{command}
|
||||
int channel = -1;
|
||||
char command[32] = {0};
|
||||
|
||||
if (sscanf(topic, "servo/control/%2d/%8s", &channel, command) != 2) {
|
||||
Serial.printf("Invalid servo topic: %s\n", topic);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel < 0 || channel >= MAX_SERVOS) {
|
||||
Serial.printf("Invalid servo channel: %d\n", channel);
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("Servo %d command: %s = %s\n", channel, command, payload);
|
||||
|
||||
if (strcmp(command, "angle") == 0) {
|
||||
int angle = atoi(payload);
|
||||
if (angle >= 0 && angle <= 180) {
|
||||
setServoAngle(channel, (uint8_t)angle);
|
||||
checkAndPublishServoAngle(channel);
|
||||
checkAndPublishServoStatus(channel);
|
||||
}
|
||||
}
|
||||
else if (strcmp(command, "enable") == 0) {
|
||||
bool enable = (strcmp(payload, "on") == 0 || strcmp(payload, "1") == 0 || strcmp(payload, "true") == 0);
|
||||
if (enable) {
|
||||
enableServo(channel);
|
||||
} else {
|
||||
disableServo(channel);
|
||||
}
|
||||
checkAndPublishServoStatus(channel);
|
||||
}
|
||||
}
|
||||
|
||||
/////
|
||||
|
||||
// ============================================
|
||||
// ГЛАВНЫЙ ЦИКЛ ПУБЛИКАЦИИ
|
||||
// ============================================
|
||||
|
||||
static void publishTelemetry() {
|
||||
unsigned long now = millis();
|
||||
if (now - last_feedback_time >= 500) {
|
||||
publishMotorTelemetry();
|
||||
publishVL53L0XTelemetry();
|
||||
last_feedback_time = now;
|
||||
}
|
||||
}
|
||||
|
||||
void mqttTask(void *parameter) {
|
||||
static unsigned long last_stack_check = 0;
|
||||
|
||||
setup_wifi();
|
||||
client.setServer(MQTT_SERVER, MQTT_PORT);
|
||||
client.setCallback(callback);
|
||||
|
||||
servoInit();
|
||||
|
||||
for (;;) {
|
||||
if (!client.connected()) reconnect();
|
||||
client.loop();
|
||||
publishTelemetry();
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
}
|
||||
11
arduino_code/Test/src/mqtt_handler.h
Normal file
11
arduino_code/Test/src/mqtt_handler.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#ifndef MQTT_HANDLER_H
|
||||
#define MQTT_HANDLER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <PubSubClient.h>
|
||||
|
||||
void mqttTask(void *parameter);
|
||||
void handleServoMQTTCommand(const char* topic, const char* payload);
|
||||
|
||||
#endif
|
||||
98
arduino_code/Test/src/servo_control.cpp
Normal file
98
arduino_code/Test/src/servo_control.cpp
Normal file
@@ -0,0 +1,98 @@
|
||||
#include "servo_control.h"
|
||||
#include "mqtt_handler.h"
|
||||
#include "config.h"
|
||||
|
||||
static Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver();
|
||||
static bool servo_initialized = false;
|
||||
|
||||
|
||||
|
||||
void servoInit() {
|
||||
Serial.println("Initializing PCA9685 servo driver...");
|
||||
|
||||
// Инициализация I2C на пинах 21 (SDA) и 22 (SCL)
|
||||
Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
|
||||
|
||||
pwm.begin();
|
||||
pwm.setOscillatorFrequency(27000000);
|
||||
pwm.setPWMFreq(50); // 50 Hz для сервоприводов
|
||||
|
||||
delay(10);
|
||||
|
||||
servo_initialized = true;
|
||||
Serial.println("PCA9685 initialized successfully");
|
||||
|
||||
// Инициализируем все каналы как выключенные
|
||||
for (int i = 0; i < MAX_SERVOS; i++) {
|
||||
current_angles[i] = 90; // Начальное положение - середина
|
||||
servo_enabled[i] = false;
|
||||
disableServo(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Преобразование угла (0-180) в длину импульса
|
||||
uint16_t angleToPulse(uint8_t angle) {
|
||||
if (angle > 180) angle = 180;
|
||||
return map(angle, 0, 180, SERVO_MIN_PULSE, SERVO_MAX_PULSE);
|
||||
}
|
||||
|
||||
// Преобразование длины импульса в угол
|
||||
uint8_t pulseToAngle(uint16_t pulse) {
|
||||
if (pulse < SERVO_MIN_PULSE) return 0;
|
||||
if (pulse > SERVO_MAX_PULSE) return 180;
|
||||
return map(pulse, SERVO_MIN_PULSE, SERVO_MAX_PULSE, 0, 180);
|
||||
}
|
||||
|
||||
void setServoAngle(uint8_t channel, uint8_t angle) {
|
||||
if (!servo_initialized || channel >= MAX_SERVOS) return;
|
||||
|
||||
if (angle > 180) angle = 180;
|
||||
|
||||
current_angles[channel] = angle;
|
||||
servo_enabled[channel] = true;
|
||||
|
||||
uint16_t pulse = angleToPulse(angle);
|
||||
setServoPulse(channel, pulse);
|
||||
|
||||
Serial.printf("Servo %d: angle=%d, pulse=%d\n", channel, angle, pulse);
|
||||
}
|
||||
|
||||
void setServoPulse(uint8_t channel, uint16_t pulse) {
|
||||
if (!servo_initialized || channel >= MAX_SERVOS) return;
|
||||
|
||||
// Преобразование микросекунд в тики PCA9685
|
||||
// PCA9685 имеет 4096 тиков на период при 50Hz = 20000 мкс
|
||||
// 1 мкс = 4096 / 20000 = 0.2048 тика
|
||||
double pulselength = 4096.0 / 20000.0; // тиков на микросекунду
|
||||
uint16_t ticks = pulse * pulselength;
|
||||
|
||||
pwm.setPWM(channel, 0, ticks);
|
||||
}
|
||||
|
||||
void enableServo(uint8_t channel) {
|
||||
if (!servo_initialized || channel >= MAX_SERVOS) return;
|
||||
|
||||
servo_enabled[channel] = true;
|
||||
setServoAngle(channel, current_angles[channel]);
|
||||
|
||||
Serial.printf("Servo %d: ENABLED\n", channel);
|
||||
}
|
||||
|
||||
void disableServo(uint8_t channel) {
|
||||
if (!servo_initialized || channel >= MAX_SERVOS) return;
|
||||
|
||||
servo_enabled[channel] = false;
|
||||
pwm.setPWM(channel, 0, 0); // Отключаем сигнал
|
||||
|
||||
Serial.printf("Servo %d: DISABLED\n", channel);
|
||||
}
|
||||
|
||||
uint8_t getServoAngle(uint8_t channel) {
|
||||
if (channel >= MAX_SERVOS) return 0;
|
||||
return current_angles[channel];
|
||||
}
|
||||
|
||||
bool isServoEnabled(uint8_t channel) {
|
||||
if (channel >= MAX_SERVOS) return false;
|
||||
return servo_enabled[channel];
|
||||
}
|
||||
32
arduino_code/Test/src/servo_control.h
Normal file
32
arduino_code/Test/src/servo_control.h
Normal file
@@ -0,0 +1,32 @@
|
||||
#ifndef SERVO_H
|
||||
#define SERVO_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Adafruit_PWMServoDriver.h>
|
||||
|
||||
|
||||
// Хранение текущего состояния сервоприводов
|
||||
#define MAX_SERVOS 16
|
||||
static uint8_t current_angles[MAX_SERVOS] = {0};
|
||||
static bool servo_enabled[MAX_SERVOS] = {false};
|
||||
static uint8_t last_published_angles[MAX_SERVOS] = {255};
|
||||
static bool last_published_status[MAX_SERVOS] = {false};
|
||||
|
||||
// Минимальная и максимальная длина импульса для сервопривода (в микросекундах)
|
||||
static const uint16_t SERVO_MIN_PULSE = 600;
|
||||
static const uint16_t SERVO_MAX_PULSE = 2400;
|
||||
|
||||
// Инициализация сервопривода
|
||||
void servoInit();
|
||||
|
||||
// Управление сервоприводом
|
||||
void setServoAngle(uint8_t channel, uint8_t angle);
|
||||
void setServoPulse(uint8_t channel, uint16_t pulse);
|
||||
void enableServo(uint8_t channel);
|
||||
void disableServo(uint8_t channel);
|
||||
|
||||
// Получение состояния
|
||||
uint8_t getServoAngle(uint8_t channel);
|
||||
bool isServoEnabled(uint8_t channel);
|
||||
|
||||
#endif // SERVO_H
|
||||
477
arduino_code/Test/src/vl53l0x_sensor.cpp
Normal file
477
arduino_code/Test/src/vl53l0x_sensor.cpp
Normal file
@@ -0,0 +1,477 @@
|
||||
#include "vl53l0x_sensor.h"
|
||||
#include "config.h"
|
||||
|
||||
// ============================================
|
||||
// КОНСТАНТЫ
|
||||
// ============================================
|
||||
|
||||
#define TCA9548A_ADDRESS 0x70
|
||||
#define TCA9548A_CHANNELS 8
|
||||
|
||||
#define MIN_DISTANCE_MM 30
|
||||
#define OUT_OF_RANGE_VALUE 65535
|
||||
|
||||
#define FILTER_SIZE 5
|
||||
|
||||
// ============================================
|
||||
// ПРОФИЛИ РЕЖИМОВ
|
||||
// ============================================
|
||||
|
||||
static const ModeProfile MODES[MODE_COUNT] = {
|
||||
{
|
||||
"HIGH_ACCURACY",
|
||||
200000, 14, 10, 0.5f, 500, 2
|
||||
},
|
||||
{
|
||||
"PRECISION",
|
||||
66000, 14, 10, 0.3f, 1000, 5
|
||||
},
|
||||
{
|
||||
"DEFAULT",
|
||||
33000, 14, 10, 0.25f, 1200, 15
|
||||
},
|
||||
{
|
||||
"LONG_RANGE",
|
||||
33000, 18, 14, 0.1f, 2000, 40
|
||||
},
|
||||
{
|
||||
"ULTRA_LONG",
|
||||
100000, 18, 14, 0.05f, 2500, 80
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ
|
||||
// ============================================
|
||||
|
||||
static VL53L0X sensor;
|
||||
static Preferences preferences;
|
||||
|
||||
static MeasurementMode current_mode = MODE_DEFAULT;
|
||||
static bool sensor_present[TCA9548A_CHANNELS] = {false};
|
||||
static bool channel_enabled[TCA9548A_CHANNELS] = {false};
|
||||
static CalibrationData calibration[TCA9548A_CHANNELS];
|
||||
|
||||
static uint16_t filter_buffer[TCA9548A_CHANNELS][FILTER_SIZE];
|
||||
static uint8_t filter_index[TCA9548A_CHANNELS] = {0};
|
||||
|
||||
static bool calibration_in_progress = false;
|
||||
static uint8_t calibration_channel = 0;
|
||||
static uint16_t calibration_near_raw = 0;
|
||||
static uint16_t calibration_near_known = 0;
|
||||
|
||||
// ============================================
|
||||
// TCA9548A МУЛЬТИПЛЕКСОР
|
||||
// ============================================
|
||||
|
||||
static void TCA9548A_Select(uint8_t channel) {
|
||||
Wire.beginTransmission(TCA9548A_ADDRESS);
|
||||
Wire.write((channel < TCA9548A_CHANNELS) ? (1 << channel) : 0x00);
|
||||
Wire.endTransmission();
|
||||
delay(3);
|
||||
}
|
||||
|
||||
static void TCA9548A_DisableAll() {
|
||||
Wire.beginTransmission(TCA9548A_ADDRESS);
|
||||
Wire.write(0x00);
|
||||
Wire.endTransmission();
|
||||
}
|
||||
|
||||
static bool checkTCA9548A() {
|
||||
Wire.beginTransmission(TCA9548A_ADDRESS);
|
||||
return (Wire.endTransmission() == 0);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NVS (СОХРАНЕНИЕ В FLASH)
|
||||
// ============================================
|
||||
|
||||
static void loadCalibration() {
|
||||
preferences.begin("vl53_cal", true);
|
||||
|
||||
for (uint8_t ch = 0; ch < TCA9548A_CHANNELS; ch++) {
|
||||
char key[16];
|
||||
snprintf(key, sizeof(key), "ch%d", ch);
|
||||
|
||||
size_t len = preferences.getBytesLength(key);
|
||||
if (len == sizeof(CalibrationData)) {
|
||||
preferences.getBytes(key, &calibration[ch], sizeof(CalibrationData));
|
||||
} else {
|
||||
calibration[ch].valid = false;
|
||||
calibration[ch].scale = 1.0f;
|
||||
calibration[ch].offset = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
int saved_mode = preferences.getInt("mode", MODE_DEFAULT);
|
||||
if (saved_mode >= 0 && saved_mode < MODE_COUNT) {
|
||||
current_mode = (MeasurementMode)saved_mode;
|
||||
}
|
||||
|
||||
preferences.end();
|
||||
}
|
||||
|
||||
static void saveCalibration(uint8_t channel) {
|
||||
preferences.begin("vl53_cal", false);
|
||||
char key[16];
|
||||
snprintf(key, sizeof(key), "ch%d", channel);
|
||||
preferences.putBytes(key, &calibration[channel], sizeof(CalibrationData));
|
||||
preferences.end();
|
||||
}
|
||||
|
||||
static void saveMode() {
|
||||
preferences.begin("vl53_cal", false);
|
||||
preferences.putInt("mode", (int)current_mode);
|
||||
preferences.end();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ПРИМЕНЕНИЕ РЕЖИМА
|
||||
// ============================================
|
||||
|
||||
static bool applyModeProfile() {
|
||||
const ModeProfile& profile = MODES[current_mode];
|
||||
|
||||
sensor.setTimeout(500);
|
||||
|
||||
if (!sensor.init()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sensor.setAddress(0x29);
|
||||
sensor.setSignalRateLimit(profile.signal_rate_limit);
|
||||
|
||||
sensor.setVcselPulsePeriod(VL53L0X::VcselPeriodPreRange, profile.vcsel_prerange);
|
||||
sensor.setVcselPulsePeriod(VL53L0X::VcselPeriodFinalRange, profile.vcsel_final);
|
||||
sensor.setMeasurementTimingBudget(profile.timing_budget_us);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// КАЛИБРОВКА И ФИЛЬТРАЦИЯ
|
||||
// ============================================
|
||||
|
||||
static uint16_t applyCalibration(uint8_t channel, uint16_t raw_mm) {
|
||||
if (!calibration[channel].valid || raw_mm == OUT_OF_RANGE_VALUE) {
|
||||
return raw_mm;
|
||||
}
|
||||
|
||||
float calibrated = (float)raw_mm * calibration[channel].scale + calibration[channel].offset;
|
||||
|
||||
const ModeProfile& profile = MODES[current_mode];
|
||||
if (calibrated < MIN_DISTANCE_MM) calibrated = MIN_DISTANCE_MM;
|
||||
if (calibrated > profile.max_range_mm) return OUT_OF_RANGE_VALUE;
|
||||
|
||||
return (uint16_t)calibrated;
|
||||
}
|
||||
|
||||
static uint16_t applyFilter(uint8_t channel, uint16_t new_value) {
|
||||
if (new_value == OUT_OF_RANGE_VALUE) return OUT_OF_RANGE_VALUE;
|
||||
|
||||
filter_buffer[channel][filter_index[channel]] = new_value;
|
||||
filter_index[channel] = (filter_index[channel] + 1) % FILTER_SIZE;
|
||||
|
||||
uint32_t sum = 0;
|
||||
uint8_t count = 0;
|
||||
for (uint8_t i = 0; i < FILTER_SIZE; i++) {
|
||||
if (filter_buffer[channel][i] != 0) {
|
||||
sum += filter_buffer[channel][i];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return (count > 0) ? (uint16_t)(sum / count) : new_value;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ПУБЛИЧНЫЕ ФУНКЦИИ
|
||||
// ============================================
|
||||
|
||||
void vl53l0xInit() {
|
||||
Serial.println("Initializing VL53L0X sensors...");
|
||||
|
||||
Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
|
||||
Wire.setClock(400000);
|
||||
delay(100);
|
||||
|
||||
if (!checkTCA9548A()) {
|
||||
Serial.println("ERROR: TCA9548A not found!");
|
||||
return;
|
||||
}
|
||||
Serial.println("✓ TCA9548A found");
|
||||
TCA9548A_DisableAll();
|
||||
|
||||
loadCalibration();
|
||||
Serial.printf("✓ Loaded mode: %s\n", MODES[current_mode].name);
|
||||
|
||||
Serial.println("\nScanning VL53L0X channels:");
|
||||
for (uint8_t channel = 0; channel < TCA9548A_CHANNELS; channel++) {
|
||||
TCA9548A_Select(channel);
|
||||
delay(20);
|
||||
|
||||
if (applyModeProfile()) {
|
||||
sensor_present[channel] = true;
|
||||
channel_enabled[channel] = true;
|
||||
Serial.printf(" ✓ Channel %d: VL53L0X found", channel);
|
||||
if (calibration[channel].valid) Serial.print(" [CALIBRATED]");
|
||||
Serial.println();
|
||||
|
||||
memset(filter_buffer[channel], 0, sizeof(filter_buffer[channel]));
|
||||
filter_index[channel] = 0;
|
||||
} else {
|
||||
sensor_present[channel] = false;
|
||||
channel_enabled[channel] = false;
|
||||
Serial.printf(" ✗ Channel %d: No device\n", channel);
|
||||
}
|
||||
|
||||
TCA9548A_DisableAll();
|
||||
delay(5);
|
||||
}
|
||||
|
||||
Serial.printf("\n✓ VL53L0X initialized: %d sensors found\n\n",
|
||||
vl53l0xGetChannelCount());
|
||||
}
|
||||
|
||||
void vl53l0xLoop() {
|
||||
// Внутренняя логика датчиков (если нужна)
|
||||
// Сейчас вся публикация в mqtt_handle.cpp
|
||||
}
|
||||
|
||||
bool vl53l0xSetMode(MeasurementMode mode) {
|
||||
if (mode < 0 || mode >= MODE_COUNT) {
|
||||
Serial.printf("ERROR: Invalid mode %d\n", mode);
|
||||
return false;
|
||||
}
|
||||
|
||||
current_mode = mode;
|
||||
saveMode();
|
||||
|
||||
const ModeProfile& profile = MODES[current_mode];
|
||||
Serial.printf("✓ Mode changed to: %s (max %d mm)\n",
|
||||
profile.name, profile.max_range_mm);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
MeasurementMode vl53l0xGetMode() {
|
||||
return current_mode;
|
||||
}
|
||||
|
||||
const ModeProfile* vl53l0xGetModeProfile() {
|
||||
return &MODES[current_mode];
|
||||
}
|
||||
|
||||
uint16_t vl53l0xReadDistance(uint8_t channel) {
|
||||
if (channel >= TCA9548A_CHANNELS || !sensor_present[channel] || !channel_enabled[channel]) {
|
||||
return OUT_OF_RANGE_VALUE;
|
||||
}
|
||||
|
||||
TCA9548A_Select(channel);
|
||||
|
||||
if (!applyModeProfile()) {
|
||||
TCA9548A_DisableAll();
|
||||
return OUT_OF_RANGE_VALUE;
|
||||
}
|
||||
|
||||
uint16_t distance = sensor.readRangeSingleMillimeters();
|
||||
bool timeout = sensor.timeoutOccurred();
|
||||
|
||||
TCA9548A_DisableAll();
|
||||
|
||||
if (distance == 65535 || timeout) return OUT_OF_RANGE_VALUE;
|
||||
|
||||
const ModeProfile& profile = MODES[current_mode];
|
||||
if (distance < MIN_DISTANCE_MM || distance > profile.max_range_mm) {
|
||||
return OUT_OF_RANGE_VALUE;
|
||||
}
|
||||
|
||||
uint16_t filtered = applyFilter(channel, distance);
|
||||
uint16_t calibrated = applyCalibration(channel, filtered);
|
||||
|
||||
return calibrated;
|
||||
}
|
||||
|
||||
uint16_t vl53l0xReadRawDistance(uint8_t channel) {
|
||||
if (channel >= TCA9548A_CHANNELS || !sensor_present[channel] || !channel_enabled[channel]) {
|
||||
return OUT_OF_RANGE_VALUE;
|
||||
}
|
||||
|
||||
TCA9548A_Select(channel);
|
||||
|
||||
if (!applyModeProfile()) {
|
||||
TCA9548A_DisableAll();
|
||||
return OUT_OF_RANGE_VALUE;
|
||||
}
|
||||
|
||||
uint16_t distance = sensor.readRangeSingleMillimeters();
|
||||
bool timeout = sensor.timeoutOccurred();
|
||||
|
||||
TCA9548A_DisableAll();
|
||||
|
||||
if (distance == 65535 || timeout) return OUT_OF_RANGE_VALUE;
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
bool vl53l0xIsChannelActive(uint8_t channel) {
|
||||
return (channel < TCA9548A_CHANNELS && sensor_present[channel] && channel_enabled[channel]);
|
||||
}
|
||||
|
||||
int vl53l0xGetChannelCount() {
|
||||
int count = 0;
|
||||
for (uint8_t i = 0; i < TCA9548A_CHANNELS; i++) {
|
||||
if (sensor_present[i]) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
bool vl53l0xStartCalibration(uint8_t channel, uint16_t near_known_mm) {
|
||||
if (channel >= TCA9548A_CHANNELS || !sensor_present[channel]) {
|
||||
Serial.printf("ERROR: Channel %d not available\n", channel);
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("Starting calibration for channel %d (near point: %d mm)\n",
|
||||
channel, near_known_mm);
|
||||
|
||||
uint32_t sum = 0;
|
||||
uint8_t valid = 0;
|
||||
|
||||
for (uint8_t i = 0; i < 20; i++) {
|
||||
uint16_t d = vl53l0xReadRawDistance(channel);
|
||||
if (d != OUT_OF_RANGE_VALUE) {
|
||||
sum += d;
|
||||
valid++;
|
||||
}
|
||||
delay(50);
|
||||
}
|
||||
|
||||
if (valid == 0) {
|
||||
Serial.println("ERROR: Failed to read near point");
|
||||
return false;
|
||||
}
|
||||
|
||||
calibration_near_raw = (uint16_t)(sum / valid);
|
||||
calibration_near_known = near_known_mm;
|
||||
calibration_channel = channel;
|
||||
calibration_in_progress = true;
|
||||
|
||||
Serial.printf("✓ Near point captured: raw=%d mm, actual=%d mm\n",
|
||||
calibration_near_raw, calibration_near_known);
|
||||
Serial.println("Now place object at FAR point and call vl53l0xFinishCalibration()");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool vl53l0xFinishCalibration(uint8_t channel, uint16_t far_known_mm) {
|
||||
if (!calibration_in_progress || channel != calibration_channel) {
|
||||
Serial.println("ERROR: Calibration not in progress or wrong channel");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("Finishing calibration for channel %d (far point: %d mm)\n",
|
||||
channel, far_known_mm);
|
||||
|
||||
uint32_t sum = 0;
|
||||
uint8_t valid = 0;
|
||||
|
||||
for (uint8_t i = 0; i < 20; i++) {
|
||||
uint16_t d = vl53l0xReadRawDistance(channel);
|
||||
if (d != OUT_OF_RANGE_VALUE) {
|
||||
sum += d;
|
||||
valid++;
|
||||
}
|
||||
delay(50);
|
||||
}
|
||||
|
||||
if (valid == 0) {
|
||||
Serial.println("ERROR: Failed to read far point");
|
||||
calibration_in_progress = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t far_raw = (uint16_t)(sum / valid);
|
||||
|
||||
Serial.printf("✓ Far point captured: raw=%d mm, actual=%d mm\n",
|
||||
far_raw, far_known_mm);
|
||||
|
||||
if (far_raw == calibration_near_raw) {
|
||||
Serial.println("ERROR: Raw values are identical");
|
||||
calibration_in_progress = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
float scale = (float)(far_known_mm - calibration_near_known) /
|
||||
(float)(far_raw - calibration_near_raw);
|
||||
float offset = (float)calibration_near_known -
|
||||
(float)calibration_near_raw * scale;
|
||||
|
||||
calibration[channel].valid = true;
|
||||
calibration[channel].near_raw = calibration_near_raw;
|
||||
calibration[channel].near_known = calibration_near_known;
|
||||
calibration[channel].far_raw = far_raw;
|
||||
calibration[channel].far_known = far_known_mm;
|
||||
calibration[channel].scale = scale;
|
||||
calibration[channel].offset = offset;
|
||||
|
||||
saveCalibration(channel);
|
||||
|
||||
Serial.printf("✓ Calibration complete: scale=%.5f, offset=%.2f\n", scale, offset);
|
||||
|
||||
calibration_in_progress = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void vl53l0xClearCalibration(uint8_t channel) {
|
||||
if (channel >= TCA9548A_CHANNELS) return;
|
||||
|
||||
calibration[channel].valid = false;
|
||||
calibration[channel].scale = 1.0f;
|
||||
calibration[channel].offset = 0.0f;
|
||||
saveCalibration(channel);
|
||||
|
||||
Serial.printf("✓ Channel %d calibration cleared\n", channel);
|
||||
}
|
||||
|
||||
bool vl53l0xIsCalibrated(uint8_t channel) {
|
||||
return (channel < TCA9548A_CHANNELS && calibration[channel].valid);
|
||||
}
|
||||
|
||||
CalibrationData vl53l0xGetCalibration(uint8_t channel) {
|
||||
if (channel >= TCA9548A_CHANNELS) {
|
||||
CalibrationData empty = {false, 0, 0, 0, 0, 1.0f, 0.0f};
|
||||
return empty;
|
||||
}
|
||||
return calibration[channel];
|
||||
}
|
||||
|
||||
void vl53l0xEnableChannel(uint8_t channel, bool enable) {
|
||||
if (channel >= TCA9548A_CHANNELS) return;
|
||||
|
||||
if (!sensor_present[channel]) {
|
||||
Serial.printf("ERROR: Channel %d not present\n", channel);
|
||||
return;
|
||||
}
|
||||
|
||||
channel_enabled[channel] = enable;
|
||||
Serial.printf("✓ Channel %d %s\n", channel, enable ? "enabled" : "disabled");
|
||||
}
|
||||
|
||||
bool vl53l0xIsChannelEnabled(uint8_t channel) {
|
||||
return (channel < TCA9548A_CHANNELS && channel_enabled[channel]);
|
||||
}
|
||||
|
||||
bool vl53l0xIsChannelPresent(uint8_t channel) {
|
||||
return (channel < TCA9548A_CHANNELS && sensor_present[channel]);
|
||||
}
|
||||
|
||||
void sensorTask(void *parameter) {
|
||||
vl53l0xInit();
|
||||
|
||||
for (;;) {
|
||||
vl53l0xLoop();
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
}
|
||||
}
|
||||
68
arduino_code/Test/src/vl53l0x_sensor.h
Normal file
68
arduino_code/Test/src/vl53l0x_sensor.h
Normal file
@@ -0,0 +1,68 @@
|
||||
#ifndef VL53L0X_SENSOR_H
|
||||
#define VL53L0X_SENSOR_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <VL53L0X.h>
|
||||
#include <Wire.h>
|
||||
#include <Preferences.h>
|
||||
|
||||
// Режимы измерения
|
||||
enum MeasurementMode {
|
||||
MODE_HIGH_ACCURACY = 0,
|
||||
MODE_PRECISION = 1,
|
||||
MODE_DEFAULT = 2,
|
||||
MODE_LONG_RANGE = 3,
|
||||
MODE_ULTRA_LONG = 4,
|
||||
MODE_COUNT = 5
|
||||
};
|
||||
|
||||
struct ModeProfile {
|
||||
const char* name;
|
||||
uint32_t timing_budget_us;
|
||||
uint8_t vcsel_prerange;
|
||||
uint8_t vcsel_final;
|
||||
float signal_rate_limit;
|
||||
uint16_t max_range_mm;
|
||||
uint8_t accuracy_mm;
|
||||
};
|
||||
|
||||
struct CalibrationData {
|
||||
bool valid;
|
||||
uint16_t near_raw;
|
||||
uint16_t near_known;
|
||||
uint16_t far_raw;
|
||||
uint16_t far_known;
|
||||
float scale;
|
||||
float offset;
|
||||
};
|
||||
|
||||
// Инициализация и цикл
|
||||
void vl53l0xInit();
|
||||
void vl53l0xLoop();
|
||||
|
||||
// Управление режимами
|
||||
bool vl53l0xSetMode(MeasurementMode mode);
|
||||
MeasurementMode vl53l0xGetMode();
|
||||
const ModeProfile* vl53l0xGetModeProfile();
|
||||
|
||||
// Чтение данных
|
||||
uint16_t vl53l0xReadDistance(uint8_t channel);
|
||||
uint16_t vl53l0xReadRawDistance(uint8_t channel);
|
||||
bool vl53l0xIsChannelActive(uint8_t channel);
|
||||
int vl53l0xGetChannelCount();
|
||||
|
||||
// Калибровка
|
||||
bool vl53l0xStartCalibration(uint8_t channel, uint16_t near_known_mm);
|
||||
bool vl53l0xFinishCalibration(uint8_t channel, uint16_t far_known_mm);
|
||||
void vl53l0xClearCalibration(uint8_t channel);
|
||||
bool vl53l0xIsCalibrated(uint8_t channel);
|
||||
CalibrationData vl53l0xGetCalibration(uint8_t channel);
|
||||
|
||||
// Управление каналами
|
||||
void vl53l0xEnableChannel(uint8_t channel, bool enable);
|
||||
bool vl53l0xIsChannelEnabled(uint8_t channel);
|
||||
bool vl53l0xIsChannelPresent(uint8_t channel);
|
||||
|
||||
void sensorTask(void *parameter);
|
||||
|
||||
#endif // VL53L0X_SENSOR_H
|
||||
11
arduino_code/Test/test/README
Normal file
11
arduino_code/Test/test/README
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
This directory is intended for PlatformIO Test Runner and project tests.
|
||||
|
||||
Unit Testing is a software testing method by which individual units of
|
||||
source code, sets of one or more MCU program modules together with associated
|
||||
control data, usage procedures, and operating procedures, are tested to
|
||||
determine whether they are fit for use. Unit testing finds problems early
|
||||
in the development cycle.
|
||||
|
||||
More information about PlatformIO Unit Testing:
|
||||
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||
1
backend_control/.gitignore
vendored
Normal file
1
backend_control/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/testing
|
||||
61
backend_control/README.md
Normal file
61
backend_control/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Интерфейс управления
|
||||
|
||||

|
||||
|
||||
## Область Управления
|
||||
|
||||
`Целевой RPM` - Задается уставка скорости в диапозоне от -1000 до 1000 оборотов в минуту
|
||||
|
||||
`Ток` - Уставка тока в диапозоне 0 - 100%
|
||||
|
||||
`StallGuard` -
|
||||
|
||||
`Микрошаг` - Уставка микрошага
|
||||
|
||||
`Сброс счетчика шагов` - Сброс счетчика шагов
|
||||
|
||||
## Область Режимы
|
||||
|
||||
`Аппаратное вкл. (Driver EN)` - Аппаратное Включение/Выключение драйвера tmc2209
|
||||
|
||||
`Программное вкл. (TMC Chip)` - Программное Включение/Выключение драйвера tmc2209
|
||||
|
||||
`StealthChop (Тихий)` - Включение/Выключение тихого режима работы шагового двигателя.
|
||||
|
||||
`CoolStep (Энергосбер.)` - Включение/Выключение энергосберегающего режима работы шагового двигателя.
|
||||
|
||||
## Область Телеметрия и Статусы
|
||||
|
||||
`Текущий RPM (Факт)`
|
||||
|
||||
`Всего шагов`
|
||||
|
||||
`Вращение`
|
||||
|
||||
`SG Result` - Для отслеживания нагрузки на вал или момента срыва шагов. Резкое падение значения sg_result при движении обычно означает столкновение или заклинивание механизма.
|
||||
|
||||
`Interstep (ns)`
|
||||
|
||||
`Current Scaling`
|
||||
|
||||
## Область Ошибки и Флаги
|
||||
|
||||
`Перегрев`
|
||||
|
||||
`КЗ на землю`
|
||||
|
||||
`Обрыв нагрузки`
|
||||
|
||||
`StealthChop активен`
|
||||
|
||||
`Остановка (Standstill)`
|
||||
|
||||

|
||||
|
||||
## Область Управление Серво
|
||||
|
||||
Тут можно задать угол сервопривода и отключить сервопривод
|
||||
|
||||
## Облать Статус и телеметрия
|
||||
|
||||
Вывод информации о сервоприводе включен или выключен. Так же выведен текущий угол.
|
||||
6
backend_control/create_venv.sh
Normal file
6
backend_control/create_venv.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
python -m venv testing
|
||||
|
||||
source testing/bin/activate
|
||||
pip install -r requirements.txt
|
||||
460
backend_control/gui.py
Normal file
460
backend_control/gui.py
Normal file
@@ -0,0 +1,460 @@
|
||||
import customtkinter as ctk
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
# ================= НАСТРОЙКИ =================
|
||||
MQTT_BROKER = "192.168.0.200"
|
||||
MQTT_PORT = 1883
|
||||
MQTT_USER = "test"
|
||||
MQTT_PASSWORD = "1234"
|
||||
|
||||
MAX_SERVOS = 4
|
||||
MAX_SENSORS = 8 # Количество каналов VL53L0X
|
||||
|
||||
# Цвета
|
||||
COLOR_OK = "#28a745"
|
||||
COLOR_ERR = "#dc3545"
|
||||
COLOR_OFF = "#555555"
|
||||
COLOR_ACTIVE = "#00d2ff"
|
||||
COLOR_PENDING = "#ffaa00"
|
||||
COLOR_WARN = "#ff9900" # Для out_of_range
|
||||
|
||||
class MotorSCADA:
|
||||
def __init__(self):
|
||||
ctk.set_appearance_mode("Dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
self.root = ctk.CTk()
|
||||
self.root.title("🚀 Motor, Servo & Sensor SCADA")
|
||||
self.root.geometry("1200x900")
|
||||
self.root.minsize(1100, 800)
|
||||
|
||||
# Флаги ожидания
|
||||
self.driver_pending = False
|
||||
self.tmc_pending = False
|
||||
self.servo_pending = {i: False for i in range(MAX_SERVOS)}
|
||||
self.sensor_pending = {i: False for i in range(MAX_SENSORS)}
|
||||
|
||||
self.setup_gui()
|
||||
self.setup_mqtt()
|
||||
|
||||
def setup_gui(self):
|
||||
# --- Шапка ---
|
||||
header = ctk.CTkFrame(self.root, height=60)
|
||||
header.pack(fill="x", padx=20, pady=(20, 10))
|
||||
header.pack_propagate(False)
|
||||
ctk.CTkLabel(header, text="Motor, Servo & Sensor SCADA", font=ctk.CTkFont(size=24, weight="bold")).pack(side="left", padx=20)
|
||||
self.lbl_status = ctk.CTkLabel(header, text="● Отключено", text_color=COLOR_ERR, font=ctk.CTkFont(size=16, weight="bold"))
|
||||
self.lbl_status.pack(side="right", padx=20)
|
||||
|
||||
# --- Вкладки ---
|
||||
self.tabview = ctk.CTkTabview(self.root)
|
||||
self.tabview.pack(fill="both", expand=True, padx=20, pady=10)
|
||||
|
||||
self.tab_motor = self.tabview.add("Шаговый двигатель (TMC2209)")
|
||||
self.tab_servo = self.tabview.add(f"Сервоприводы (0-{MAX_SERVOS-1})")
|
||||
self.tab_sensor = self.tabview.add(f"Датчики VL53L0X (0-{MAX_SENSORS-1})")
|
||||
|
||||
self.create_motor_tab(self.tab_motor)
|
||||
self.create_servo_tab(self.tab_servo)
|
||||
self.create_sensor_tab(self.tab_sensor)
|
||||
|
||||
# ================= ВКЛАДКА ШАГОВОГО ДВИГАТЕЛЯ =================
|
||||
# (Код для мотора остался без изменений, чтобы не раздувать ответ,
|
||||
# но в реальном файле он должен быть здесь полностью)
|
||||
def create_motor_tab(self, parent):
|
||||
grid = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
grid.pack(fill="both", expand=True)
|
||||
grid.grid_columnconfigure((0, 1, 2), weight=1, uniform="col")
|
||||
grid.grid_rowconfigure(0, weight=1)
|
||||
self.create_control_frame(grid)
|
||||
self.create_modes_frame(grid)
|
||||
self.create_telemetry_frame(grid)
|
||||
|
||||
def create_control_frame(self, parent):
|
||||
frame = ctk.CTkFrame(parent)
|
||||
frame.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
|
||||
ctk.CTkLabel(frame, text="⚙️ Управление", font=ctk.CTkFont(size=18, weight="bold")).pack(pady=(10, 20))
|
||||
ctk.CTkLabel(frame, text="Целевой RPM:").pack(anchor="w", padx=20)
|
||||
rpm_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
rpm_frame.pack(fill="x", padx=20, pady=5)
|
||||
self.sld_rpm = ctk.CTkSlider(rpm_frame, from_=-1000, to=1000, command=self.on_rpm_slider_change)
|
||||
self.sld_rpm.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
self.ent_rpm = ctk.CTkEntry(rpm_frame, width=80, justify="right")
|
||||
self.ent_rpm.insert(0, "0"); self.ent_rpm.pack(side="left", padx=(0, 10))
|
||||
self.ent_rpm.bind("<Return>", self.on_rpm_entry_apply); self.ent_rpm.bind("<FocusOut>", self.on_rpm_entry_apply)
|
||||
self.lbl_rpm_val = ctk.CTkLabel(rpm_frame, text="0", width=50); self.lbl_rpm_val.pack(side="right")
|
||||
|
||||
ctk.CTkLabel(frame, text="Ток (%):").pack(anchor="w", padx=20, pady=(15,0))
|
||||
cur_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
cur_frame.pack(fill="x", padx=20, pady=5)
|
||||
self.sld_current = ctk.CTkSlider(cur_frame, from_=0, to=100, command=self.on_current_change)
|
||||
self.sld_current.pack(side="left", fill="x", expand=True)
|
||||
self.lbl_cur_val = ctk.CTkLabel(cur_frame, text="50", width=50); self.lbl_cur_val.pack(side="right", padx=(10, 0))
|
||||
|
||||
ctk.CTkLabel(frame, text="StallGuard (0-255):").pack(anchor="w", padx=20, pady=(15,0))
|
||||
self.ent_sg = ctk.CTkEntry(frame, width=100); self.ent_sg.insert(0, "0")
|
||||
self.ent_sg.pack(anchor="w", padx=20, pady=5)
|
||||
ctk.CTkButton(frame, text="Применить SG", width=150, command=self.on_sg_apply).pack(pady=5)
|
||||
|
||||
ctk.CTkLabel(frame, text="Микрошаги:").pack(anchor="w", padx=20, pady=(15,0))
|
||||
self.opt_msteps = ctk.CTkOptionMenu(frame, values=["1", "2", "4", "8", "16", "32", "64", "128", "256"], command=self.on_msteps_change)
|
||||
self.opt_msteps.set("16"); self.opt_msteps.pack(anchor="w", padx=20, pady=5)
|
||||
ctk.CTkButton(frame, text="Сбросить счетчик шагов", fg_color="#dc3545", hover_color="#b02a37", command=self.on_reset_steps).pack(pady=20)
|
||||
|
||||
def create_modes_frame(self, parent):
|
||||
frame = ctk.CTkFrame(parent)
|
||||
frame.grid(row=0, column=1, sticky="nsew", padx=10)
|
||||
ctk.CTkLabel(frame, text="🔌 Режимы и Включение", font=ctk.CTkFont(size=18, weight="bold")).pack(pady=(10, 20))
|
||||
self.sw_driver, self.led_driver_fb = self.create_switch_with_feedback(frame, "Аппаратное вкл. (Driver EN)", self.on_driver_change)
|
||||
self.sw_tmc_enable, self.led_tmc_fb = self.create_switch_with_feedback(frame, "Программное вкл. (TMC Chip)", self.on_tmc_enable_change)
|
||||
ctk.CTkFrame(frame, height=2, fg_color="#4a4a6a").pack(fill="x", padx=20, pady=15)
|
||||
self.sw_stealth = self.create_switch(frame, "StealthChop (Тихий)", self.on_stealth_change)
|
||||
self.sw_cool = self.create_switch(frame, "CoolStep (Энергосбер.)", self.on_cool_change)
|
||||
|
||||
def create_telemetry_frame(self, parent):
|
||||
frame = ctk.CTkFrame(parent)
|
||||
frame.grid(row=0, column=2, sticky="nsew", padx=(10, 0))
|
||||
ctk.CTkLabel(frame, text="📊 Телеметрия и Статусы", font=ctk.CTkFont(size=18, weight="bold")).pack(pady=(10, 10))
|
||||
tel_frame = ctk.CTkFrame(frame); tel_frame.pack(fill="x", padx=10, pady=5)
|
||||
self.lbl_fb_rpm = self.create_telemetry_row(tel_frame, "Текущий RPM:")
|
||||
self.lbl_fb_steps = self.create_telemetry_row(tel_frame, "Всего шагов:")
|
||||
self.lbl_fb_run = self.create_telemetry_row(tel_frame, "Вращение:")
|
||||
self.lbl_fb_sg = self.create_telemetry_row(tel_frame, "SG Result:")
|
||||
self.lbl_fb_interstep = self.create_telemetry_row(tel_frame, "Interstep:")
|
||||
self.lbl_fb_cscale = self.create_telemetry_row(tel_frame, "Current Scaling:")
|
||||
ctk.CTkLabel(frame, text="🚨 Ошибки и Флаги", font=ctk.CTkFont(size=16, weight="bold")).pack(pady=(15, 5))
|
||||
stat_frame = ctk.CTkFrame(frame); stat_frame.pack(fill="x", padx=10, pady=5)
|
||||
self.led_over_temp = self.create_led_row(stat_frame, "Перегрев:")
|
||||
self.led_short_gnd = self.create_led_row(stat_frame, "КЗ на землю:")
|
||||
self.led_open_load = self.create_led_row(stat_frame, "Обрыв нагрузки:")
|
||||
self.led_stealth_act = self.create_led_row(stat_frame, "StealthChop активен:")
|
||||
self.led_standstill = self.create_led_row(stat_frame, "Остановка:")
|
||||
|
||||
# ================= ВКЛАДКА СЕРВОПРИВОДОВ =================
|
||||
def create_servo_tab(self, parent):
|
||||
grid = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
grid.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
grid.grid_columnconfigure((0, 1), weight=1, uniform="col")
|
||||
grid.grid_rowconfigure((0, 1), weight=1, uniform="row")
|
||||
self.servo_ui = {}
|
||||
for i in range(MAX_SERVOS):
|
||||
row, col = divmod(i, 2)
|
||||
frame = ctk.CTkFrame(grid)
|
||||
frame.grid(row=row, column=col, sticky="nsew", padx=10, pady=10)
|
||||
self.servo_ui[i] = self.create_servo_card(frame, i)
|
||||
|
||||
def create_servo_card(self, parent, channel):
|
||||
ui = {}
|
||||
ctk.CTkLabel(parent, text=f"🦾 Сервопривод #{channel}", font=ctk.CTkFont(size=16, weight="bold")).pack(pady=(10, 10))
|
||||
ctk.CTkLabel(parent, text="Угол (0-180°):").pack(anchor="w", padx=20)
|
||||
ang_frame = ctk.CTkFrame(parent, fg_color="transparent"); ang_frame.pack(fill="x", padx=20, pady=5)
|
||||
sld = ctk.CTkSlider(ang_frame, from_=0, to=180, command=lambda val, ch=channel: self.on_servo_ang_slider(ch, val))
|
||||
sld.pack(side="left", fill="x", expand=True, padx=(0, 10)); sld.set(90)
|
||||
ent = ctk.CTkEntry(ang_frame, width=60, justify="right"); ent.insert(0, "90"); ent.pack(side="left", padx=(0, 10))
|
||||
ent.bind("<Return>", lambda event, ch=channel: self.on_servo_ang_entry(ch, event))
|
||||
ent.bind("<FocusOut>", lambda event, ch=channel: self.on_servo_ang_entry(ch, event))
|
||||
lbl_val = ctk.CTkLabel(ang_frame, text="90", width=40); lbl_val.pack(side="right")
|
||||
ui['slider_ang'], ui['entry_ang'], ui['lbl_ang_val'] = sld, ent, lbl_val
|
||||
|
||||
sw, led_fb = self.create_switch_with_feedback(parent, "Включить серво", lambda ch=channel: self.on_servo_enable_change(ch))
|
||||
ui['switch_en'], ui['led_fb'] = sw, led_fb
|
||||
|
||||
tel_frame = ctk.CTkFrame(parent); tel_frame.pack(fill="x", padx=10, pady=15)
|
||||
ui['lbl_fb_ang'] = self.create_telemetry_row(tel_frame, "Текущий угол:")
|
||||
stat_frame = ctk.CTkFrame(parent); stat_frame.pack(fill="x", padx=10, pady=5)
|
||||
ui['led_status'] = self.create_led_row(stat_frame, "Статус:")
|
||||
return ui
|
||||
|
||||
# ================= ВКЛАДКА ДАТЧИКОВ VL53L0X =================
|
||||
def create_sensor_tab(self, parent):
|
||||
main_frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
# --- Глобальное управление ---
|
||||
global_frame = ctk.CTkFrame(main_frame)
|
||||
global_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
ctk.CTkLabel(global_frame, text="📏 Глобальные настройки VL53L0X", font=ctk.CTkFont(size=18, weight="bold")).pack(pady=(10, 5))
|
||||
|
||||
info_frame = ctk.CTkFrame(global_frame, fg_color="transparent")
|
||||
info_frame.pack(fill="x", padx=20, pady=10)
|
||||
|
||||
self.lbl_sensor_mode = self.create_telemetry_row(info_frame, "Режим:")
|
||||
self.lbl_sensor_mode_id = self.create_telemetry_row(info_frame, "ID режима:")
|
||||
self.lbl_sensor_max_range = self.create_telemetry_row(info_frame, "Макс. дальность (мм):")
|
||||
|
||||
ctrl_frame = ctk.CTkFrame(global_frame, fg_color="transparent")
|
||||
ctrl_frame.pack(fill="x", padx=20, pady=(0, 10))
|
||||
|
||||
ctk.CTkLabel(ctrl_frame, text="Выбрать режим:").pack(side="left", padx=(0, 10))
|
||||
# Предполагаем, что режимов от 0 до 4 (Default, HighAccuracy, LongRange, HighSpeed)
|
||||
self.opt_sensor_mode = ctk.CTkOptionMenu(ctrl_frame, values=["0", "1", "2", "3", "4"], width=100, command=self.on_sensor_mode_change)
|
||||
self.opt_sensor_mode.set("0"); self.opt_sensor_mode.pack(side="left", padx=(0, 20))
|
||||
|
||||
ctk.CTkButton(ctrl_frame, text="🔄 Принудительно обновить все", fg_color="#007bff", hover_color="#0056b3", command=self.on_sensor_publish_all).pack(side="right")
|
||||
|
||||
# --- Сетка каналов (Scrollable) ---
|
||||
scroll_frame = ctk.CTkScrollableFrame(main_frame)
|
||||
scroll_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
scroll_frame.grid_columnconfigure((0, 1, 2, 3), weight=1, uniform="col")
|
||||
|
||||
self.sensor_ui = {}
|
||||
for i in range(MAX_SENSORS):
|
||||
row, col = divmod(i, 4)
|
||||
frame = ctk.CTkFrame(scroll_frame)
|
||||
frame.grid(row=row, column=col, sticky="nsew", padx=5, pady=5)
|
||||
self.sensor_ui[i] = self.create_sensor_card(frame, i)
|
||||
|
||||
def create_sensor_card(self, parent, channel):
|
||||
ui = {}
|
||||
ctk.CTkLabel(parent, text=f"📡 Канал #{channel}", font=ctk.CTkFont(size=14, weight="bold")).pack(pady=(5, 5))
|
||||
|
||||
# Включение и калибровка
|
||||
sw, led_fb = self.create_switch_with_feedback(parent, "Включить", lambda ch=channel: self.on_sensor_enable_change(ch))
|
||||
ui['switch_en'], ui['led_fb'] = sw, led_fb
|
||||
|
||||
cal_stat_frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
cal_stat_frame.pack(fill="x", padx=10, pady=5)
|
||||
ctk.CTkLabel(cal_stat_frame, text="Калибровка:").pack(side="left")
|
||||
ui['led_calibrated'] = ctk.CTkLabel(cal_stat_frame, text="●", font=ctk.CTkFont(size=16), text_color=COLOR_OFF)
|
||||
ui['led_calibrated'].pack(side="right")
|
||||
|
||||
# Поля калибровки
|
||||
cal_ctrl_frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
cal_ctrl_frame.pack(fill="x", padx=10, pady=5)
|
||||
|
||||
ctk.CTkLabel(cal_ctrl_frame, text="Ближняя (мм):").pack(anchor="w")
|
||||
ent_near = ctk.CTkEntry(cal_ctrl_frame, width=60, justify="right"); ent_near.insert(0, "50"); ent_near.pack(side="left", padx=(0, 5))
|
||||
btn_start = ctk.CTkButton(cal_ctrl_frame, text="Старт", width=60, height=28, command=lambda ch=channel, e=ent_near: self.on_sensor_cal_start(ch, e))
|
||||
btn_start.pack(side="right")
|
||||
|
||||
ctk.CTkLabel(cal_ctrl_frame, text="Дальняя (мм):").pack(anchor="w", pady=(5,0))
|
||||
ent_far = ctk.CTkEntry(cal_ctrl_frame, width=60, justify="right"); ent_far.insert(0, "500"); ent_far.pack(side="left", padx=(0, 5), pady=(5,0))
|
||||
btn_finish = ctk.CTkButton(cal_ctrl_frame, text="Финиш", width=60, height=28, command=lambda ch=channel, e=ent_far: self.on_sensor_cal_finish(ch, e))
|
||||
btn_finish.pack(side="right")
|
||||
|
||||
ctk.CTkButton(parent, text="Сбросить калибровку", fg_color="#6c757d", hover_color="#5a6268", height=28, command=lambda ch=channel: self.on_sensor_clear_cal(ch)).pack(pady=5)
|
||||
|
||||
ui['ent_near'], ui['ent_far'] = ent_near, ent_far
|
||||
|
||||
# Телеметрия
|
||||
tel_frame = ctk.CTkFrame(parent)
|
||||
tel_frame.pack(fill="x", padx=5, pady=5)
|
||||
ui['lbl_dist'] = self.create_telemetry_row(tel_frame, "Дист. (мм):")
|
||||
ui['lbl_raw'] = self.create_telemetry_row(tel_frame, "Сырое (мм):")
|
||||
|
||||
return ui
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ GUI =================
|
||||
def create_switch(self, parent, text, command):
|
||||
frame = ctk.CTkFrame(parent, fg_color="transparent"); frame.pack(fill="x", padx=10, pady=5)
|
||||
ctk.CTkLabel(frame, text=text).pack(side="left")
|
||||
switch = ctk.CTkSwitch(frame, text="", command=command); switch.pack(side="right")
|
||||
return switch
|
||||
|
||||
def create_switch_with_feedback(self, parent, text, command):
|
||||
frame = ctk.CTkFrame(parent, fg_color="transparent"); frame.pack(fill="x", padx=10, pady=5)
|
||||
ctk.CTkLabel(frame, text=text).pack(side="left")
|
||||
feedback_led = ctk.CTkLabel(frame, text="●", font=ctk.CTkFont(size=18), text_color=COLOR_OFF)
|
||||
feedback_led.pack(side="right", padx=(10, 0))
|
||||
switch = ctk.CTkSwitch(frame, text="", command=command); switch.pack(side="right", padx=(10, 0))
|
||||
return switch, feedback_led
|
||||
|
||||
def create_telemetry_row(self, parent, text):
|
||||
frame = ctk.CTkFrame(parent, fg_color="transparent"); frame.pack(fill="x", pady=2)
|
||||
ctk.CTkLabel(frame, text=text, anchor="w", font=ctk.CTkFont(size=12)).pack(side="left")
|
||||
val_lbl = ctk.CTkLabel(frame, text="-", font=ctk.CTkFont(weight="bold", size=12), text_color=COLOR_ACTIVE, anchor="e")
|
||||
val_lbl.pack(side="right")
|
||||
return val_lbl
|
||||
|
||||
def create_led_row(self, parent, text):
|
||||
frame = ctk.CTkFrame(parent, fg_color="transparent"); frame.pack(fill="x", pady=2)
|
||||
ctk.CTkLabel(frame, text=text, anchor="w", font=ctk.CTkFont(size=12)).pack(side="left")
|
||||
led_lbl = ctk.CTkLabel(frame, text="●", font=ctk.CTkFont(size=16), text_color=COLOR_OFF)
|
||||
led_lbl.pack(side="right")
|
||||
return led_lbl
|
||||
|
||||
# ================= MQTT =================
|
||||
def setup_mqtt(self):
|
||||
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="python_scada")
|
||||
self.client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
|
||||
self.client.on_connect = self.on_mqtt_connect
|
||||
self.client.on_disconnect = self.on_mqtt_disconnect
|
||||
self.client.on_message = self.on_mqtt_message
|
||||
try:
|
||||
self.client.connect(MQTT_BROKER, MQTT_PORT, 60)
|
||||
self.client.loop_start()
|
||||
except Exception as e:
|
||||
print(f"Ошибка подключения: {e}")
|
||||
self.update_status(False)
|
||||
|
||||
def on_mqtt_connect(self, client, userdata, flags, reason_code, properties):
|
||||
if reason_code == 0:
|
||||
self.root.after(0, self.update_status, True)
|
||||
client.subscribe("motor/feedback/#")
|
||||
client.subscribe("servo/+/feedback/#")
|
||||
client.subscribe("sensor/feedback/#") # Подписка на датчики
|
||||
else:
|
||||
self.root.after(0, self.update_status, False)
|
||||
|
||||
def on_mqtt_disconnect(self, client, userdata, flags, reason_code, properties):
|
||||
self.root.after(0, self.update_status, False)
|
||||
|
||||
def on_mqtt_message(self, client, userdata, msg):
|
||||
self.root.after(0, self.process_feedback, msg.topic, msg.payload.decode('utf-8'))
|
||||
|
||||
def process_feedback(self, topic, val):
|
||||
parts = topic.split('/')
|
||||
|
||||
# --- MOTOR ---
|
||||
if parts[0] == 'motor' and parts[1] == 'feedback':
|
||||
if topic == "motor/feedback/rpm": self.lbl_fb_rpm.configure(text=val)
|
||||
elif topic == "motor/feedback/totalsteps": self.lbl_fb_steps.configure(text=val)
|
||||
elif topic == "motor/feedback/is_run":
|
||||
is_run = val == "true"
|
||||
self.lbl_fb_run.configure(text="Да" if is_run else "Нет", text_color=COLOR_OK if is_run else COLOR_ERR)
|
||||
elif topic == "motor/feedback/tmc/current_percent": self.lbl_cur_val.configure(text=val); self.sld_current.set(int(val))
|
||||
elif topic == "motor/feedback/tmc/microsteps": self.opt_msteps.set(val)
|
||||
elif topic == "motor/feedback/tmc/sg_result": self.lbl_fb_sg.configure(text=val)
|
||||
elif topic == "motor/feedback/tmc/interstep_duration": self.lbl_fb_interstep.configure(text=val)
|
||||
elif topic == "motor/feedback/tmc/status/current_scaling": self.lbl_fb_cscale.configure(text=val)
|
||||
elif topic == "motor/feedback/tmc/status/over_temp": self.update_led(self.led_over_temp, val, True)
|
||||
elif topic == "motor/feedback/tmc/status/short_to_ground": self.update_led(self.led_short_gnd, val, True)
|
||||
elif topic == "motor/feedback/tmc/status/open_load": self.update_led(self.led_open_load, val, True)
|
||||
elif topic == "motor/feedback/tmc/status/stealth_chop_active": self.update_led(self.led_stealth_act, val, False)
|
||||
elif topic == "motor/feedback/tmc/status/standstill": self.update_led(self.led_standstill, val, False)
|
||||
elif topic == "motor/feedback/driver/status":
|
||||
is_on = val == "on"; self.driver_pending = False
|
||||
if self.sw_driver.get() != is_on: self.sw_driver.select() if is_on else self.sw_driver.deselect()
|
||||
self.led_driver_fb.configure(text_color=COLOR_OK if is_on else COLOR_OFF)
|
||||
elif topic == "motor/feedback/tmc/status":
|
||||
is_on = val == "on"; self.tmc_pending = False
|
||||
if self.sw_tmc_enable.get() != is_on: self.sw_tmc_enable.select() if is_on else self.sw_tmc_enable.deselect()
|
||||
self.led_tmc_fb.configure(text_color=COLOR_OK if is_on else COLOR_OFF)
|
||||
|
||||
# --- SERVO ---
|
||||
elif parts[0] == 'servo' and len(parts) == 4 and parts[2] == 'feedback':
|
||||
try:
|
||||
ch = int(parts[1]); param = parts[3]
|
||||
if ch in self.servo_ui:
|
||||
ui = self.servo_ui[ch]
|
||||
if param == 'angle': ui['lbl_fb_ang'].configure(text=val)
|
||||
elif param == 'status':
|
||||
is_on = val == "on"; self.servo_pending[ch] = False
|
||||
if ui['switch_en'].get() != is_on: ui['switch_en'].select() if is_on else ui['switch_en'].deselect()
|
||||
ui['led_fb'].configure(text_color=COLOR_OK if is_on else COLOR_OFF)
|
||||
self.update_led(ui['led_status'], val, False)
|
||||
except ValueError: pass
|
||||
|
||||
# --- SENSOR ---
|
||||
elif parts[0] == 'sensor' and parts[1] == 'feedback':
|
||||
if len(parts) == 3: # Глобальные sensor/feedback/mode...
|
||||
param = parts[2]
|
||||
if param == 'mode': self.lbl_sensor_mode.configure(text=val)
|
||||
elif param == 'mode_id': self.lbl_sensor_mode_id.configure(text=val); self.opt_sensor_mode.set(val)
|
||||
elif param == 'max_range': self.lbl_sensor_max_range.configure(text=val)
|
||||
elif len(parts) == 4: # Канальные sensor/feedback/{ch}/...
|
||||
try:
|
||||
ch = int(parts[2]); param = parts[3]
|
||||
if ch in self.sensor_ui:
|
||||
ui = self.sensor_ui[ch]
|
||||
if param == 'status':
|
||||
is_on = val == "on"; self.sensor_pending[ch] = False
|
||||
if ui['switch_en'].get() != is_on: ui['switch_en'].select() if is_on else ui['switch_en'].deselect()
|
||||
ui['led_fb'].configure(text_color=COLOR_OK if is_on else COLOR_OFF)
|
||||
elif param == 'calibrated':
|
||||
is_cal = val == "true"
|
||||
ui['led_calibrated'].configure(text_color=COLOR_OK if is_cal else COLOR_OFF)
|
||||
elif param == 'distance':
|
||||
if val == "out_of_range":
|
||||
ui['lbl_dist'].configure(text="Вне диапазона", text_color=COLOR_WARN)
|
||||
else:
|
||||
ui['lbl_dist'].configure(text=val, text_color=COLOR_ACTIVE)
|
||||
elif param == 'raw':
|
||||
ui['lbl_raw'].configure(text=val, text_color=COLOR_ACTIVE)
|
||||
except ValueError: pass
|
||||
|
||||
# ================= ОБРАБОТЧИКИ СОБЫТИЙ =================
|
||||
def publish(self, topic, payload):
|
||||
if self.client.is_connected(): self.client.publish(topic, str(payload), qos=1)
|
||||
|
||||
def update_status(self, is_online):
|
||||
self.lbl_status.configure(text="● Подключено" if is_online else "● Отключено", text_color=COLOR_OK if is_online else COLOR_ERR)
|
||||
|
||||
def update_led(self, label, val, is_error):
|
||||
is_true = val in ["true", "1", "on"]
|
||||
label.configure(text_color=COLOR_ERR if (is_true and is_error) else (COLOR_OK if is_true else COLOR_OFF))
|
||||
|
||||
# --- Motor Handlers ---
|
||||
def on_rpm_slider_change(self, value):
|
||||
int_val = int(value); self.ent_rpm.delete(0, ctk.END); self.ent_rpm.insert(0, str(int_val))
|
||||
self.lbl_rpm_val.configure(text=str(int_val)); self.publish("motor/control/rpm", int_val)
|
||||
def on_rpm_entry_apply(self, event=None):
|
||||
try:
|
||||
int_val = max(-1000, min(1000, int(self.ent_rpm.get())))
|
||||
self.sld_rpm.set(int_val); self.lbl_rpm_val.configure(text=str(int_val)); self.publish("motor/control/rpm", int_val)
|
||||
except ValueError: self.ent_rpm.delete(0, ctk.END); self.ent_rpm.insert(0, str(int(self.sld_rpm.get())))
|
||||
def on_current_change(self, value):
|
||||
int_val = int(value); self.lbl_cur_val.configure(text=str(int_val)); self.publish("motor/control/tmc/current_percent", int_val)
|
||||
def on_sg_apply(self):
|
||||
val = self.ent_sg.get()
|
||||
if val.isdigit() and 0 <= int(val) <= 255: self.publish("motor/control/tmc/stallguard", int(val))
|
||||
def on_msteps_change(self, choice): self.publish("motor/control/tmc/microsteps", int(choice))
|
||||
def on_reset_steps(self): self.publish("motor/control/totalsteps/reset", "1")
|
||||
def on_driver_change(self):
|
||||
is_on = self.sw_driver.get(); self.driver_pending = True; self.led_driver_fb.configure(text_color=COLOR_PENDING)
|
||||
self.publish("motor/control/driver", "on" if is_on else "off")
|
||||
def on_tmc_enable_change(self):
|
||||
is_on = self.sw_tmc_enable.get(); self.tmc_pending = True; self.led_tmc_fb.configure(text_color=COLOR_PENDING)
|
||||
self.publish("motor/control/tmc/enable", "on" if is_on else "off")
|
||||
def on_stealth_change(self): self.publish("motor/control/tmc/stealthchop", "on" if self.sw_stealth.get() else "off")
|
||||
def on_cool_change(self): self.publish("motor/control/tmc/coolstep", "on" if self.sw_cool.get() else "off")
|
||||
|
||||
# --- Servo Handlers ---
|
||||
def on_servo_ang_slider(self, channel, value):
|
||||
int_val = int(value); ui = self.servo_ui[channel]
|
||||
ui['entry_ang'].delete(0, ctk.END); ui['entry_ang'].insert(0, str(int_val))
|
||||
ui['lbl_ang_val'].configure(text=str(int_val)); self.publish(f"servo/control/{channel}/angle", int_val)
|
||||
def on_servo_ang_entry(self, channel, event=None):
|
||||
ui = self.servo_ui[channel]
|
||||
try:
|
||||
int_val = max(0, min(180, int(ui['entry_ang'].get())))
|
||||
ui['slider_ang'].set(int_val); ui['lbl_ang_val'].configure(text=str(int_val)); self.publish(f"servo/control/{channel}/angle", int_val)
|
||||
except ValueError: ui['entry_ang'].delete(0, ctk.END); ui['entry_ang'].insert(0, str(int(ui['slider_ang'].get())))
|
||||
def on_servo_enable_change(self, channel):
|
||||
ui = self.servo_ui[channel]; is_on = ui['switch_en'].get()
|
||||
self.servo_pending[channel] = True; ui['led_fb'].configure(text_color=COLOR_PENDING)
|
||||
self.publish(f"servo/control/{channel}/enable", "on" if is_on else "off")
|
||||
|
||||
# --- Sensor Handlers ---
|
||||
def on_sensor_mode_change(self, choice):
|
||||
self.publish("sensor/control/mode", int(choice))
|
||||
|
||||
def on_sensor_publish_all(self):
|
||||
self.publish("sensor/control/publish_all", "1")
|
||||
|
||||
def on_sensor_enable_change(self, channel):
|
||||
ui = self.sensor_ui[channel]; is_on = ui['switch_en'].get()
|
||||
self.sensor_pending[channel] = True; ui['led_fb'].configure(text_color=COLOR_PENDING)
|
||||
self.publish(f"sensor/control/enable/{channel}", "on" if is_on else "off")
|
||||
|
||||
def on_sensor_cal_start(self, channel, entry_widget):
|
||||
val = entry_widget.get()
|
||||
if val.isdigit():
|
||||
self.publish(f"sensor/control/calibrate/start/{channel}", int(val))
|
||||
|
||||
def on_sensor_cal_finish(self, channel, entry_widget):
|
||||
val = entry_widget.get()
|
||||
if val.isdigit():
|
||||
self.publish(f"sensor/control/calibrate/finish/{channel}", int(val))
|
||||
|
||||
def on_sensor_clear_cal(self, channel):
|
||||
self.publish(f"sensor/control/clear_cal/{channel}", "1")
|
||||
|
||||
def run(self):
|
||||
self.root.mainloop()
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = MotorSCADA()
|
||||
app.run()
|
||||
BIN
backend_control/img/gui_interface.png
Normal file
BIN
backend_control/img/gui_interface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
backend_control/img/servo_interface.png
Normal file
BIN
backend_control/img/servo_interface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
2
backend_control/requirements.txt
Normal file
2
backend_control/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
paho-mqtt
|
||||
customtkinter
|
||||
2
kicad/ozon/.gitignore
vendored
Normal file
2
kicad/ozon/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.lck
|
||||
.history
|
||||
1
kicad/ozon/.history
Submodule
1
kicad/ozon/.history
Submodule
Submodule kicad/ozon/.history added at 2a8a462227
2
kicad/ozon/ozon.kicad_pcb
Normal file
2
kicad/ozon/ozon.kicad_pcb
Normal file
@@ -0,0 +1,2 @@
|
||||
(kicad_pcb (version 20260206) (generator "pcbnew") (generator_version "10.0")
|
||||
)
|
||||
105
kicad/ozon/ozon.kicad_prl
Normal file
105
kicad/ozon/ozon.kicad_prl
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"board": {
|
||||
"active_layer": 0,
|
||||
"active_layer_preset": "",
|
||||
"auto_track_width": true,
|
||||
"hidden_netclasses": [],
|
||||
"hidden_nets": [],
|
||||
"high_contrast_mode": 0,
|
||||
"net_color_mode": 1,
|
||||
"opacity": {
|
||||
"images": 0.6,
|
||||
"pads": 1.0,
|
||||
"shapes": 1.0,
|
||||
"tracks": 1.0,
|
||||
"vias": 1.0,
|
||||
"zones": 0.6
|
||||
},
|
||||
"prototype_zone_fills": false,
|
||||
"selection_filter": {
|
||||
"dimensions": true,
|
||||
"footprints": true,
|
||||
"graphics": true,
|
||||
"keepouts": true,
|
||||
"lockedItems": false,
|
||||
"otherItems": true,
|
||||
"pads": true,
|
||||
"text": true,
|
||||
"tracks": true,
|
||||
"vias": true,
|
||||
"zones": true
|
||||
},
|
||||
"visible_items": [
|
||||
"vias",
|
||||
"footprint_text",
|
||||
"footprint_anchors",
|
||||
"ratsnest",
|
||||
"grid",
|
||||
"footprints_front",
|
||||
"footprints_back",
|
||||
"footprint_values",
|
||||
"footprint_references",
|
||||
"tracks",
|
||||
"drc_errors",
|
||||
"drawing_sheet",
|
||||
"bitmaps",
|
||||
"pads",
|
||||
"zones",
|
||||
"drc_warnings",
|
||||
"drc_exclusions",
|
||||
"locked_item_shadows",
|
||||
"conflict_shadows",
|
||||
"shapes",
|
||||
"board_outline_area",
|
||||
"ly_points"
|
||||
],
|
||||
"visible_layers": "ffffffff_ffffffff_ffffffff_ffffffff",
|
||||
"zone_display_mode": 0
|
||||
},
|
||||
"git": {
|
||||
"integration_disabled": false,
|
||||
"repo_type": "",
|
||||
"repo_username": "",
|
||||
"ssh_key": ""
|
||||
},
|
||||
"meta": {
|
||||
"filename": "ozon.kicad_prl",
|
||||
"version": 5
|
||||
},
|
||||
"net_inspector_panel": {
|
||||
"col_hidden": [],
|
||||
"col_order": [],
|
||||
"col_widths": [],
|
||||
"custom_group_rules": [],
|
||||
"expanded_rows": [],
|
||||
"filter_by_net_name": true,
|
||||
"filter_by_netclass": true,
|
||||
"filter_text": "",
|
||||
"group_by_constraint": false,
|
||||
"group_by_netclass": false,
|
||||
"show_time_domain_details": false,
|
||||
"show_unconnected_nets": false,
|
||||
"show_zero_pad_nets": false,
|
||||
"sort_ascending": true,
|
||||
"sorting_column": -1
|
||||
},
|
||||
"open_jobsets": [],
|
||||
"project": {
|
||||
"files": []
|
||||
},
|
||||
"schematic": {
|
||||
"hierarchy_collapsed": [],
|
||||
"selection_filter": {
|
||||
"graphics": true,
|
||||
"images": true,
|
||||
"labels": true,
|
||||
"lockedItems": false,
|
||||
"otherItems": true,
|
||||
"pins": true,
|
||||
"ruleAreas": true,
|
||||
"symbols": true,
|
||||
"text": true,
|
||||
"wires": true
|
||||
}
|
||||
}
|
||||
}
|
||||
435
kicad/ozon/ozon.kicad_pro
Normal file
435
kicad/ozon/ozon.kicad_pro
Normal file
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"board": {
|
||||
"3dviewports": [],
|
||||
"ipc2581": {
|
||||
"bom_rev": "",
|
||||
"dist": "",
|
||||
"distpn": "",
|
||||
"internal_id": "",
|
||||
"mfg": "",
|
||||
"mpn": "",
|
||||
"sch_revision": ""
|
||||
},
|
||||
"layer_pairs": [],
|
||||
"layer_presets": [],
|
||||
"viewports": []
|
||||
},
|
||||
"boards": [],
|
||||
"component_class_settings": {
|
||||
"assignments": [],
|
||||
"meta": {
|
||||
"version": 0
|
||||
},
|
||||
"sheet_component_classes": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"cvpcb": {
|
||||
"equivalence_files": []
|
||||
},
|
||||
"erc": {
|
||||
"erc_exclusions": [],
|
||||
"meta": {
|
||||
"version": 0
|
||||
},
|
||||
"pin_map": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
]
|
||||
],
|
||||
"rule_severities": {
|
||||
"bus_definition_conflict": "error",
|
||||
"bus_entry_needed": "error",
|
||||
"bus_to_bus_conflict": "error",
|
||||
"bus_to_net_conflict": "error",
|
||||
"different_unit_footprint": "error",
|
||||
"different_unit_net": "error",
|
||||
"duplicate_reference": "error",
|
||||
"duplicate_sheet_names": "error",
|
||||
"endpoint_off_grid": "warning",
|
||||
"extra_units": "error",
|
||||
"field_name_whitespace": "warning",
|
||||
"footprint_filter": "ignore",
|
||||
"footprint_link_issues": "warning",
|
||||
"four_way_junction": "ignore",
|
||||
"ground_pin_not_ground": "warning",
|
||||
"hier_label_mismatch": "error",
|
||||
"isolated_pin_label": "warning",
|
||||
"label_dangling": "error",
|
||||
"label_multiple_wires": "warning",
|
||||
"lib_symbol_issues": "warning",
|
||||
"lib_symbol_mismatch": "warning",
|
||||
"missing_bidi_pin": "warning",
|
||||
"missing_input_pin": "warning",
|
||||
"missing_power_pin": "error",
|
||||
"missing_unit": "warning",
|
||||
"multiple_net_names": "warning",
|
||||
"net_not_bus_member": "warning",
|
||||
"no_connect_connected": "warning",
|
||||
"no_connect_dangling": "warning",
|
||||
"pin_not_connected": "error",
|
||||
"pin_not_driven": "error",
|
||||
"pin_to_pin": "warning",
|
||||
"power_pin_not_driven": "error",
|
||||
"same_local_global_label": "warning",
|
||||
"similar_label_and_power": "warning",
|
||||
"similar_labels": "warning",
|
||||
"similar_power": "warning",
|
||||
"simulation_model_issue": "ignore",
|
||||
"single_global_label": "ignore",
|
||||
"stacked_pin_name": "warning",
|
||||
"unannotated": "error",
|
||||
"unconnected_wire_endpoint": "warning",
|
||||
"undefined_netclass": "error",
|
||||
"unit_value_mismatch": "error",
|
||||
"unresolved_variable": "error",
|
||||
"wire_dangling": "error"
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"pinned_footprint_libs": [],
|
||||
"pinned_symbol_libs": []
|
||||
},
|
||||
"meta": {
|
||||
"filename": "ozon.kicad_pro",
|
||||
"version": 3
|
||||
},
|
||||
"net_settings": {
|
||||
"classes": [
|
||||
{
|
||||
"bus_width": 12,
|
||||
"clearance": 0.2,
|
||||
"diff_pair_gap": 0.25,
|
||||
"diff_pair_via_gap": 0.25,
|
||||
"diff_pair_width": 0.2,
|
||||
"line_style": 0,
|
||||
"microvia_diameter": 0.3,
|
||||
"microvia_drill": 0.1,
|
||||
"name": "Default",
|
||||
"pcb_color": "rgba(0, 0, 0, 0.000)",
|
||||
"priority": 2147483647,
|
||||
"schematic_color": "rgba(0, 0, 0, 0.000)",
|
||||
"track_width": 0.2,
|
||||
"tuning_profile": "",
|
||||
"via_diameter": 0.6,
|
||||
"via_drill": 0.3,
|
||||
"wire_width": 6
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"version": 5
|
||||
},
|
||||
"net_colors": null,
|
||||
"netclass_assignments": null,
|
||||
"netclass_patterns": []
|
||||
},
|
||||
"pcbnew": {
|
||||
"last_paths": {
|
||||
"idf": "",
|
||||
"netlist": "",
|
||||
"plot": "",
|
||||
"specctra_dsn": "",
|
||||
"vrml": ""
|
||||
},
|
||||
"page_layout_descr_file": ""
|
||||
},
|
||||
"schematic": {
|
||||
"annotate_start_num": 0,
|
||||
"annotation": {
|
||||
"method": 0,
|
||||
"sort_order": 0
|
||||
},
|
||||
"bom_export_filename": "${PROJECTNAME}.csv",
|
||||
"bom_fmt_presets": [],
|
||||
"bom_fmt_settings": {
|
||||
"field_delimiter": ",",
|
||||
"keep_line_breaks": false,
|
||||
"keep_tabs": false,
|
||||
"name": "CSV",
|
||||
"ref_delimiter": ",",
|
||||
"ref_range_delimiter": "",
|
||||
"string_delimiter": "\""
|
||||
},
|
||||
"bom_presets": [],
|
||||
"bom_settings": {
|
||||
"exclude_dnp": false,
|
||||
"fields_ordered": [
|
||||
{
|
||||
"group_by": false,
|
||||
"label": "Reference",
|
||||
"name": "Reference",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": false,
|
||||
"label": "Qty",
|
||||
"name": "${QUANTITY}",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "Value",
|
||||
"name": "Value",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "DNP",
|
||||
"name": "${DNP}",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "Exclude from BOM",
|
||||
"name": "${EXCLUDE_FROM_BOM}",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "Exclude from Board",
|
||||
"name": "${EXCLUDE_FROM_BOARD}",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "Footprint",
|
||||
"name": "Footprint",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": false,
|
||||
"label": "Datasheet",
|
||||
"name": "Datasheet",
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"filter_string": "",
|
||||
"group_symbols": true,
|
||||
"include_excluded_from_bom": true,
|
||||
"name": "Default Editing",
|
||||
"sort_asc": true,
|
||||
"sort_field": "Обозначение"
|
||||
},
|
||||
"bus_aliases": {},
|
||||
"connection_grid_size": 50.0,
|
||||
"drawing": {
|
||||
"dashed_lines_dash_length_ratio": 12.0,
|
||||
"dashed_lines_gap_length_ratio": 3.0,
|
||||
"default_line_thickness": 6.0,
|
||||
"default_text_size": 50.0,
|
||||
"field_names": [],
|
||||
"hop_over_size_choice": 0,
|
||||
"intersheets_ref_own_page": false,
|
||||
"intersheets_ref_prefix": "",
|
||||
"intersheets_ref_short": false,
|
||||
"intersheets_ref_show": false,
|
||||
"intersheets_ref_suffix": "",
|
||||
"junction_size_choice": 3,
|
||||
"label_size_ratio": 0.375,
|
||||
"operating_point_overlay_i_precision": 3,
|
||||
"operating_point_overlay_i_range": "~A",
|
||||
"operating_point_overlay_v_precision": 3,
|
||||
"operating_point_overlay_v_range": "~V",
|
||||
"overbar_offset_ratio": 1.23,
|
||||
"pin_symbol_size": 25.0,
|
||||
"text_offset_ratio": 0.15
|
||||
},
|
||||
"legacy_lib_dir": "",
|
||||
"legacy_lib_list": [],
|
||||
"meta": {
|
||||
"version": 1
|
||||
},
|
||||
"page_layout_descr_file": "",
|
||||
"plot_directory": "",
|
||||
"reuse_designators": true,
|
||||
"subpart_first_id": 65,
|
||||
"subpart_id_separator": 0,
|
||||
"top_level_sheets": [
|
||||
{
|
||||
"filename": "ozon.kicad_sch",
|
||||
"name": "Корневой лист",
|
||||
"uuid": "8eb30ef0-8ef0-4108-b9d0-4c3ae489af42"
|
||||
}
|
||||
],
|
||||
"used_designators": "R1,M1-5,#PWR1-10,U1-6",
|
||||
"variants": []
|
||||
},
|
||||
"sheets": [
|
||||
[
|
||||
"8eb30ef0-8ef0-4108-b9d0-4c3ae489af42",
|
||||
"Корневой лист"
|
||||
]
|
||||
],
|
||||
"text_variables": {},
|
||||
"tuning_profiles": {
|
||||
"meta": {
|
||||
"version": 0
|
||||
},
|
||||
"tuning_profiles_impedance_geometric": []
|
||||
}
|
||||
}
|
||||
6460
kicad/ozon/ozon.kicad_sch
Normal file
6460
kicad/ozon/ozon.kicad_sch
Normal file
File diff suppressed because it is too large
Load Diff
26
specification/README.md
Normal file
26
specification/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Наброски будущей схемы конвейера
|
||||
|
||||

|
||||
|
||||
## Список материалов:
|
||||
|
||||
В этот список необходимо доложить энкодер для двигателя!
|
||||
|
||||
| Название | Сумма, ₽ | Количество |
|
||||
| :--- | :--- | :--- |
|
||||
| Медная лента для удаления припоя / Оплетка для выпайки диаметр 2 мм длина 1.5 м | 164 | 1 |
|
||||
| Набор проводов для пайки | 855 | 1 |
|
||||
| Флюс гель универсальный безотмывочный, для пайки микросхем и компонентов Flux RMA-223-UV-10г | 162 | 1 |
|
||||
| Припой для пайки с канифолью 1мм 50гр ПОС-61 на катушке (ГОСТ) | 423 | 1 |
|
||||
| ALIENTEK Паяльник 140 Вт, 7 предметов | 4 985 | 1 |
|
||||
| Преобразователь DC-DC понижающий с 8-60V до 1-36V 15A max | 805 | 1 |
|
||||
| Беспаечная макетная плата (breadboard) MB-102, 830 точек, для Arduino и прочих устройств | 1 112 | 2 |
|
||||
| 120 шт. Провода перемычки для макетных плат, соединительные провода для модулей, контроллеров arduino (10 см) 3 вида по 40 шт папа-папа, мама-мама, папа-мама | 636 | 2 |
|
||||
| Homeled, Блок питания, 12V, 200W, 180-265 вольт. С клеммами. Импульсный для светодиодных лент и светильников | 599 | 1 |
|
||||
| Сервопривод MG996R, 10 шт, 180 , металлические шестерни, для Arduino, роботов и RC, размеры стандарт | 2 990 | 1 |
|
||||
| PWM PCA9685 драйвер на 16 сервоприводов расширитель портов с I2C интерфейсом для Led и Servo (12 bit, I2C) в IIC/I2C/TWI/SPI c тестером сервоприводов 3 режима, набор | 1 178 | 2 |
|
||||
| Модуль I2C-мультиплексора CJMCU-9548 на базе TCA9548A / PCA9548A | 532 | 2 |
|
||||
| Модуль VL53L0X лазерный дальномер GY-530 (до 2м, питание 3-5В, I2C) | 1064 | 4 |
|
||||
| Модуль ESP32 TYPE-C CH340C + плата расширения ESP32 | 1040 | 2 |
|
||||
|
||||
[Ссылка на товары](https://www.ozon.ru/cart?share=wGhPkQd)
|
||||
54
specification/mindmap.md
Normal file
54
specification/mindmap.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Алгоритм работы сортировщика
|
||||
|
||||
```mermaid
|
||||
mindmap
|
||||
root((Алгоритм работы))
|
||||
Этап 1: Ожидание и Движение
|
||||
Arduino запускает шаговый двигатель
|
||||
Лента движется с постоянной скоростью
|
||||
Этап 2: Детекция и Анализ
|
||||
Объект проходит под датчиком и камерой
|
||||
Предмет соответствует габаритам?
|
||||
Да
|
||||
Есть признак круга в сечении?
|
||||
Да - Требуется доупаковка
|
||||
Секция 2
|
||||
Нет - Подходит для сортировки
|
||||
Секция 3
|
||||
Нет
|
||||
Не подходит по габаритам
|
||||
Секция 1
|
||||
Этап 3: Трекинг Синхронизация
|
||||
Arduino отсчитывает шаги двигателя
|
||||
Вычисляется момент времени T
|
||||
Этап 4: Маршрутизация
|
||||
В момент T сервопривод поворачивает барьер на 45°
|
||||
Объект смещается в выбранную зону
|
||||
Сервопривод возвращается в исходное положение
|
||||
Этап 5: Завершение
|
||||
Объект попадает в накопитель
|
||||
Система возвращается в состояние Ожидание
|
||||
```
|
||||
|
||||
## Блок схема:
|
||||
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start[Этап 1: Ожидание и Движение] --> Detect[Этап 2: Детекция и Анализ]
|
||||
Detect --> SizeCheck{Предмет соответствует<br/>габаритам?}
|
||||
|
||||
SizeCheck -->|Нет| Reject[Секция 1:<br/>Не габарит]
|
||||
SizeCheck -->|Да| CircleCheck{Есть признак<br/>круга?}
|
||||
|
||||
CircleCheck -->|Да| Repack[Секция 2:<br/>Требуется доупаковка]
|
||||
CircleCheck -->|Нет| Sort[Секция 3:<br/>Подходит для сортировки]
|
||||
|
||||
Reject --> Track[Этап 3: Трекинг]
|
||||
Repack --> Track
|
||||
Sort --> Track
|
||||
|
||||
Track --> Route[Этап 4: Маршрутизация]
|
||||
Route --> End[Этап 5: Завершение]
|
||||
End --> Start
|
||||
```
|
||||
277
specification/mvp_1.drawio
Normal file
277
specification/mvp_1.drawio
Normal file
@@ -0,0 +1,277 @@
|
||||
<mxfile host="server.home">
|
||||
<diagram name="Страница-1" id="mppyHKg2Vbg7W8e0-Fd9">
|
||||
<mxGraphModel dx="3902" dy="2031" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-2" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="37" y="773" as="sourcePoint" />
|
||||
<mxPoint x="1547" y="773" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-6" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="257" y="720" as="sourcePoint" />
|
||||
<mxPoint x="1617" y="720" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-7" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="37" y="770" as="sourcePoint" />
|
||||
<mxPoint x="107" y="720" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-8" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="1547" y="774" as="sourcePoint" />
|
||||
<mxPoint x="1617" y="724" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-9" parent="1" style="rounded=0;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;" value="<font style="font-size: 30px;">1</font>" vertex="1">
|
||||
<mxGeometry height="80" width="160" x="847" y="830" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-10" parent="1" style="rounded=0;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;" value="<font style="font-size: 30px;">2</font>" vertex="1">
|
||||
<mxGeometry height="80" width="160" x="1097" y="830" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-11" parent="1" style="rounded=0;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;" value="<font style="font-size: 30px;">3</font>" vertex="1">
|
||||
<mxGeometry height="80" width="160" x="1377" y="830" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-12" parent="1" style="rounded=0;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;" value="" vertex="1">
|
||||
<mxGeometry height="80" width="160" x="67" y="680" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-13" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="227" y="680" as="sourcePoint" />
|
||||
<mxPoint x="257" y="660" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-14" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="227" y="760" as="sourcePoint" />
|
||||
<mxPoint x="257" y="740" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-16" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="67" y="680" as="sourcePoint" />
|
||||
<mxPoint x="97" y="660" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-17" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="97" y="660" as="sourcePoint" />
|
||||
<mxPoint x="257" y="660" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-18" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="257" y="740" as="sourcePoint" />
|
||||
<mxPoint x="257" y="660" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-19" parent="1" style="rounded=0;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;" value="" vertex="1">
|
||||
<mxGeometry height="80" width="50" x="477" y="390" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-20" edge="1" parent="1" source="jfPUc6vKa1uVjcLF1lrC-19" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="567" y="620" as="sourcePoint" />
|
||||
<mxPoint x="527" y="530" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-21" edge="1" parent="1" source="jfPUc6vKa1uVjcLF1lrC-19" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="487" y="520" as="sourcePoint" />
|
||||
<mxPoint x="477" y="530" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-22" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="477" y="530" as="sourcePoint" />
|
||||
<mxPoint x="527" y="530" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-23" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;dashed=1;dashPattern=12 12;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="477" y="530" as="sourcePoint" />
|
||||
<mxPoint x="417" y="750" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-24" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;dashed=1;dashPattern=12 12;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="527" y="530" as="sourcePoint" />
|
||||
<mxPoint x="587" y="750" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-26" parent="1" style="rounded=0;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;" value="" vertex="1">
|
||||
<mxGeometry height="80" width="50" x="307" y="390" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-27" edge="1" parent="1" source="jfPUc6vKa1uVjcLF1lrC-26" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;dashed=1;dashPattern=8 8;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="357" y="580" as="sourcePoint" />
|
||||
<mxPoint x="332" y="740" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-29" parent="1" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;fillColor=#f0a30a;strokeColor=#BD7000;fillStyle=solid;direction=west;gradientDirection=north;shadow=1;size=20;autosizeText=1;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontColor=#000000;fontSize=17;align=left;" value="Лазерный датчик расстояния.<br>В нашем случае он измеряет высоту объекта.<br>Так как камера у нас расположена перпендикулярно движению ленты. И корректно не сможет определить высоту объектов" vertex="1">
|
||||
<mxGeometry height="160" width="315" x="42" y="210" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-30" parent="1" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;fillColor=#f0a30a;strokeColor=#BD7000;fillStyle=solid;direction=west;gradientDirection=north;shadow=1;size=20;autosizeText=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontColor=#000000;fontSize=24;align=left;" value="Тут у нас установлена камера, первично мы ее откалибруем и сможем визуально оценить размер объекта" vertex="1">
|
||||
<mxGeometry height="160" width="315" x="387" y="210" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-32" parent="1" style="ellipse;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;" value="" vertex="1">
|
||||
<mxGeometry height="40" width="40" x="804" y="640" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-37" edge="1" parent="1" source="jfPUc6vKa1uVjcLF1lrC-38" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;fontSize=16;startSize=14;endArrow=open;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;dashed=1;dashPattern=12 12;" target="jfPUc6vKa1uVjcLF1lrC-32" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="847" y="370" as="sourcePoint" />
|
||||
<mxPoint x="967" y="710" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-38" parent="1" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;fillColor=#f0a30a;strokeColor=#BD7000;fillStyle=solid;direction=west;gradientDirection=north;shadow=1;size=20;autosizeText=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontColor=#000000;fontSize=26;align=left;" value="Датчик ультразвуковой, определят что объект приехал на сортировку" vertex="1">
|
||||
<mxGeometry height="160" width="315" x="737" y="190" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-39" edge="1" parent="1" source="jfPUc6vKa1uVjcLF1lrC-40" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=open;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.542;exitY=0.018;exitDx=0;exitDy=0;exitPerimeter=0;dashed=1;dashPattern=12 12;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="1197" y="360" as="sourcePoint" />
|
||||
<mxPoint x="937" y="610" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-40" parent="1" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;fillColor=#f0a30a;strokeColor=#BD7000;fillStyle=solid;direction=west;gradientDirection=north;shadow=1;size=20;autosizeText=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontColor=#000000;fontSize=25;align=left;" value="Сервопривод с ограничителем движения который не дает объекту двигаться дальше." vertex="1">
|
||||
<mxGeometry height="160" width="315" x="1062" y="190" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-42" connectable="0" parent="1" style="group" value="" vertex="1">
|
||||
<mxGeometry height="80" width="90" x="882" y="620" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-31" parent="jfPUc6vKa1uVjcLF1lrC-42" style="rounded=0;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DArchitects%2BDaughter;" value="" vertex="1">
|
||||
<mxGeometry height="80" width="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-34" edge="1" parent="jfPUc6vKa1uVjcLF1lrC-42" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="45" y="80" as="sourcePoint" />
|
||||
<mxPoint x="95" y="150" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-35" edge="1" parent="jfPUc6vKa1uVjcLF1lrC-42" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="95" y="150" as="sourcePoint" />
|
||||
<mxPoint x="95" y="40" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-36" edge="1" parent="jfPUc6vKa1uVjcLF1lrC-42" source="jfPUc6vKa1uVjcLF1lrC-31" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="45" y="-20" as="sourcePoint" />
|
||||
<mxPoint x="95" y="40" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-48" parent="1" style="ellipse;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;" value="" vertex="1">
|
||||
<mxGeometry height="40" width="40" x="1062" y="640" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-49" edge="1" parent="1" source="jfPUc6vKa1uVjcLF1lrC-38" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=open;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;dashed=1;dashPattern=12 12;" target="jfPUc6vKa1uVjcLF1lrC-48" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="948" y="430" as="sourcePoint" />
|
||||
<mxPoint x="877" y="720" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-50" edge="1" parent="1" source="jfPUc6vKa1uVjcLF1lrC-40" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=open;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.542;exitY=-0.004;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;dashed=1;dashPattern=12 12;" target="jfPUc6vKa1uVjcLF1lrC-44" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="1436" y="410" as="sourcePoint" />
|
||||
<mxPoint x="1167" y="673" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-51" connectable="0" parent="1" style="group" value="" vertex="1">
|
||||
<mxGeometry height="80" width="90" x="1137" y="620" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-44" parent="jfPUc6vKa1uVjcLF1lrC-51" style="rounded=0;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;" value="" vertex="1">
|
||||
<mxGeometry height="80" width="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-45" edge="1" parent="jfPUc6vKa1uVjcLF1lrC-51" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="45" y="80" as="sourcePoint" />
|
||||
<mxPoint x="140" y="90" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-46" edge="1" parent="jfPUc6vKa1uVjcLF1lrC-51" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="140" y="90" as="sourcePoint" />
|
||||
<mxPoint x="140" y="-10" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-47" edge="1" parent="jfPUc6vKa1uVjcLF1lrC-51" source="jfPUc6vKa1uVjcLF1lrC-44" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="45" y="-20" as="sourcePoint" />
|
||||
<mxPoint x="140" y="-10" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-52" connectable="0" parent="1" style="group" value="" vertex="1">
|
||||
<mxGeometry height="80" width="90" x="1407" y="620" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-53" parent="jfPUc6vKa1uVjcLF1lrC-52" style="rounded=0;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;" value="" vertex="1">
|
||||
<mxGeometry height="80" width="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-54" edge="1" parent="jfPUc6vKa1uVjcLF1lrC-52" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="45" y="80" as="sourcePoint" />
|
||||
<mxPoint x="140" y="90" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-55" edge="1" parent="jfPUc6vKa1uVjcLF1lrC-52" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="140" y="90" as="sourcePoint" />
|
||||
<mxPoint x="140" y="-10" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-56" edge="1" parent="jfPUc6vKa1uVjcLF1lrC-52" source="jfPUc6vKa1uVjcLF1lrC-53" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=none;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;endFill=0;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="45" y="-20" as="sourcePoint" />
|
||||
<mxPoint x="140" y="-10" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-57" parent="1" style="ellipse;whiteSpace=wrap;html=1;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;" value="" vertex="1">
|
||||
<mxGeometry height="40" width="40" x="1337" y="640" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-58" edge="1" parent="1" source="jfPUc6vKa1uVjcLF1lrC-38" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=open;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;dashed=1;dashPattern=12 12;" target="jfPUc6vKa1uVjcLF1lrC-57" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="957" y="530" as="sourcePoint" />
|
||||
<mxPoint x="1144" y="820" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="jfPUc6vKa1uVjcLF1lrC-59" edge="1" parent="1" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;rounded=0;fontFamily=Architects Daughter;fontSource=https://fonts.googleapis.com/css?family=Architects+Daughter;fontSize=16;startSize=14;endArrow=open;endSize=14;sourcePerimeterSpacing=8;targetPerimeterSpacing=8;curved=1;dashed=1;dashPattern=12 12;" value="">
|
||||
<mxGeometry relative="1" width="140" as="geometry">
|
||||
<Array as="points" />
|
||||
<mxPoint x="1207" y="350" as="sourcePoint" />
|
||||
<mxPoint x="1447" y="620" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
BIN
specification/mvp_1.jpg
Normal file
BIN
specification/mvp_1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
97
specification/specification.md
Normal file
97
specification/specification.md
Normal file
@@ -0,0 +1,97 @@
|
||||
Привет! Как инженер-конструктор и эксперт по электронике, я с удовольствием помогу тебе спроектировать этот макет. Задача ясна: создаем систему автоматизированной сортировки с точным управлением скоростью и маршрутизацией по высоте.
|
||||
|
||||
Давай разберем твои вопросы по порядку.
|
||||
|
||||
### 1. Выбор двигателя: Шаговый vs Коллекторный
|
||||
|
||||
Для твоей задачи — **максимально точного выдерживания скорости** и управления с микроконтроллера — выбор очевиден.
|
||||
|
||||
| Характеристика | Коллекторный двигатель (DC) | Шаговый двигатель (Stepper) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Точность скорости** | Низкая. Скорость зависит от напряжения и нагрузки. Для точности нужен энкодер (обратная связь). | **Высокая**. Скорость задается частотой импульсов. Постоянна при заданной нагрузке. |
|
||||
| **Управление** | Простое (PWM/ШИМ). | Требует специальный драйвер (например, A4988 или TMC2208). |
|
||||
| **Пуск/Стоп** | Плавный разгон, сложно точно позиционировать. | **Идеальный**. Можно точно задать количество шагов и мгновенно остановиться. |
|
||||
| **Крутящий момент** | Высокий на высоких оборотах. | Высокий на низких скоростях (идеально для ленты). |
|
||||
| **Сложность реализации** | Очень низкая. | Средняя (нужен драйвер и настройка тока). |
|
||||
|
||||
**Мой вердикт:** Используй **шаговый двигатель (например, NEMA 17)**.
|
||||
Если ты хочешь «выдерживать скорость максимально точно», обычный DC-мотор тебя разочарует: при увеличении веса объекта на ленте скорость будет падать. Шаговый же двигатель будет поддерживать заданный темп, пока не будет достигнут предел его крутящего момента.
|
||||
|
||||
---
|
||||
|
||||
### 2. Селекторы (Сервоприводы)
|
||||
|
||||
Использование сервоприводов с барьером под 45° — это классическое и эффективное решение для макета.
|
||||
|
||||
**Советы по реализации:**
|
||||
* **Механика:** Чтобы объект не застревал и не «зажевывался», сделай барьер обтекаемым или используй гладкий пластик/алюминий.
|
||||
* **Угол:** 45 градусов — оптимально, но убедись, что при срабатывании сервопривод не толкает объект слишком резко, чтобы тот не перевернулся.
|
||||
* **Логика:** В промышленном подходе, как отмечено в контексте, важно отслеживать «корректность маршрутизации» [1]. Рекомендую добавить в код проверку: действительно ли объект ушел в нужную зону, или произошла ошибка.
|
||||
|
||||
---
|
||||
|
||||
### 3. Датчик определения высоты
|
||||
|
||||
Для измерения высоты объекта сверху, когда лента движется, тебе подойдут три варианта в зависимости от требуемой точности:
|
||||
|
||||
1. **Ультразвуковой датчик (HC-SR04):** Самый дешевый и популярный.
|
||||
* *Минус:* Широкий луч (может «цеплять» борта конвейера) и низкая скорость обновления.
|
||||
2. **ИК-датчик расстояния (Sharp GP2Y0A21YK0F):** Более точный, узкий луч.
|
||||
* *Минус:* Аналоговый сигнал, чувствителен к цвету объекта (черный поглощает ИК).
|
||||
3. **Лазерный ToF-датчик (VL53L0X / VL53L1X):** **Мой выбор для этого проекта.**
|
||||
* *Плюсы:* Измеряет время полета фотона (Time-of-Flight), работает независимо от цвета объекта, очень высокая точность (до миллиметров) и очень узкий луч.
|
||||
|
||||
---
|
||||
|
||||
### Итоговая архитектура (предложение):
|
||||
|
||||
* **Контроллер:** Arduino (Uno или Mega, если планируешь расширять количество датчиков).
|
||||
* **Привод ленты:** Шаговый двигатель NEMA 17 $\rightarrow$ Драйвер A4988/TMC2208 $\rightarrow$ Arduino.
|
||||
* **Сортировка:** 3 $\times$ Сервоприводы SG90 или MG90S (с металлическим редуктором надежнее).
|
||||
* **Датчик высоты:** VL53L0X (I2C интерфейс).
|
||||
* **Питание:** Отдельный блок питания 12В для двигателя и 5В для Arduino/сервоприводов (общий минус/земля).
|
||||
|
||||
**Важное замечание из спецификации:**
|
||||
Не забудь предусмотреть в логике категорию «Не подходит для сортировки» [1]. Если датчик зафиксирует объект, который либо слишком высокий, либо слишком низкий (выходит за допустимые габариты), система должна отправить его в отдельный поток или оставить на ленте до конца [1].
|
||||
|
||||
Поскольку мы проектируем макет, важно учитывать не только функциональность, но и ограничения, упомянутые в техническом контексте: доступную площадь участка [1], разнообразие форм товаров (включая круглые в сечении) и необходимость бережного обращения с хрупкими или ценными объектами [1].
|
||||
|
||||
|
||||
# Сервоприводы с барьером — это надежный вариант, но в зависимости от того, какие объекты вы будете сортировать, рассмотрите следующие альтернативы:
|
||||
|
||||
### 1. Электромагнитные толкатели (Solenoids)
|
||||
Вместо плавного поворота барьера, соленоид дает резкий линейный толчок.
|
||||
* **Плюсы:** Очень высокая скорость срабатывания, компактность [1].
|
||||
* **Минусы:** Резкий удар. Если ваши объекты хрупкие, этот вариант **не рекомендуется**, так как он противоречит требованию к «мягкости обращения» [1].
|
||||
* **Когда использовать:** Для прочных, тяжелых или однотипных деталей.
|
||||
|
||||
### 2. Откидная платформа (Tipping Platform)
|
||||
Это участок конвейера, который может наклоняться в сторону или под ним открывается люк.
|
||||
* **Плюсы:** Позволяет перемещать объекты за счет гравитации. Это один из самых бережных способов перемещения, что важно для ценных или хрупких товаров [1].
|
||||
* **Минусы:** Требует более сложной механической интеграции в саму ленту.
|
||||
* **Когда использовать:** Если приоритетом является сохранность объекта и минимизация механических ударов.
|
||||
|
||||
### 3. Поворотный диск-распределитель (Rotating Disc)
|
||||
В конце основной ленты устанавливается диск, который поворачивается на определенный угол, направляя объект в один из трех лотков.
|
||||
* **Плюсы:** Занимает минимум места (оптимально для ограниченной площади участка [1]). Отлично работает с объектами, имеющими круг в сечении [1].
|
||||
* **Минусы:** Требует точной синхронизации остановки ленты и поворота диска.
|
||||
* **Когда использовать:** Если макет должен быть максимально компактным, а объекты имеют схожую форму (например, цилиндры или сферы).
|
||||
|
||||
### 4. Боковой конвейер (Cross-Belt / Diverter)
|
||||
Это короткий перпендикулярный отрезок ленты, который включается только тогда, когда объект должен быть сброшен в зону.
|
||||
* **Плюсы:** Самый профессиональный и «мягкий» метод. Идеален для хрупких товаров [1].
|
||||
* **Минусы:** Высокая стоимость и сложность (нужно еще 3 маленьких мотора и дополнительные ленты).
|
||||
* **Когда использовать:** Если вы хотите создать максимально приближенный к реальному промышленному производству макет.
|
||||
|
||||
### Сводная таблица для выбора:
|
||||
|
||||
| Вариант | Бережность (для хрупких [1]) | Компактность (площадь [1]) | Сложность реализации | Работа с круглыми объектами [1] |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Серво-барьер** | Средняя | Высокая | Низкая | Хорошо |
|
||||
| **Соленоид** | Низкая | Очень высокая | Низкая | Средне |
|
||||
| **Платформа** | Высокая | Средняя | Средняя | Хорошо |
|
||||
| **Диск** | Средняя | Очень высокая | Средняя | Идеально |
|
||||
| **Боковой конвейер**| Очень высокая | Низкая | Высокая | Хорошо |
|
||||
|
||||
**Моя рекомендация:**
|
||||
Если вы хотите сохранить простоту управления через Arduino, но при этом учесть требования к бережности и компактности [1], я рекомендую остаться на **сервоприводах**, но изменить конструкцию барьера: вместо «толкателя» сделать **«поворотный желоб»** или **«шлюз»**. Это позволит объекту плавно скатываться в нужную зону, не подвергаясь резким ударам.
|
||||
7
vision_classifier/.gitignore
vendored
Normal file
7
vision_classifier/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
debug_frames/
|
||||
*.jpg
|
||||
*.png
|
||||
.pytest_cache/
|
||||
23
vision_classifier/Dockerfile
Normal file
23
vision_classifier/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Vision classifier for Intel RealSense D415 on Orange PI (aarch64)
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libgl1 \
|
||||
libv4l-0 \
|
||||
v4l-utils \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Камера пробрасывается через docker-compose (--device)
|
||||
CMD ["python", "main.py", "-c", "config.yaml"]
|
||||
187
vision_classifier/INTEGRATION.md
Normal file
187
vision_classifier/INTEGRATION.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Полная инструкция: Vision + Arduino + шаговик (трек 3)
|
||||
|
||||
Код в `arduino_code/Test` **не меняем**. Связка идёт только через MQTT — те же топики, что уже использует SCADA (`backend_control/gui.py`).
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
RealSense D415 ──► Orange PI (vision_classifier)
|
||||
│
|
||||
▼ MQTT
|
||||
┌──── mosquitto :1883 ────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
ESP32 (уже прошит) SCADA gui.py
|
||||
• motor/control/* (опционально)
|
||||
• servo/control/*
|
||||
│
|
||||
▼
|
||||
TMC2209 + шаговик ленты
|
||||
серво-селекторы зон B/C/D
|
||||
```
|
||||
|
||||
## 1. Сеть и MQTT (важно)
|
||||
|
||||
На Orange PI уже крутится Docker-контейнер **mosquitto** на порту **1883**
|
||||
(логин/пароль: `test` / `1234` — как в SCADA).
|
||||
|
||||
| Кто | Куда подключается |
|
||||
|-----|-------------------|
|
||||
| `vision_classifier` | `127.0.0.1:1883` (уже в `config.yaml`) |
|
||||
| SCADA `gui.py` | `192.168.31.225` или `127.0.0.1` |
|
||||
| ESP32 (прошивка) | `MQTT_SERVER = "192.168.31.225"` (уже в `arduino_code/Test`) |
|
||||
|
||||
Orange PI в сети: **`192.168.31.225`**. Vision ходит на локальный брокер `127.0.0.1`; ESP и SCADA — на `192.168.31.225:1883`.
|
||||
|
||||
Краткая шпаргалка запуска/стопа: **[START.md](START.md)**.
|
||||
|
||||
Проверка с Orange PI:
|
||||
|
||||
```bash
|
||||
# локальный брокер на OPI
|
||||
python3 -c "
|
||||
import paho.mqtt.client as m, time
|
||||
c=m.Client(m.CallbackAPIVersion.VERSION2); c.username_pw_set('test','1234')
|
||||
c.connect('127.0.0.1',1883); c.loop_start(); time.sleep(0.5); print('MQTT OK'); c.disconnect()
|
||||
"
|
||||
```
|
||||
|
||||
MQTT Explorer: http://localhost:3000 (контейнер `mqtt-explorer`).
|
||||
|
||||
## 2. Что уже должно работать на Arduino
|
||||
|
||||
Не трогая `arduino_code/Test`, убедитесь что ESP32 в сети и отвечает на команды (через SCADA или mosquitto):
|
||||
|
||||
| Действие | Топик | Payload |
|
||||
|----------|-------|---------|
|
||||
| Вкл. драйвер | `motor/control/driver` | `on` |
|
||||
| Вкл. TMC | `motor/control/tmc/enable` | `on` |
|
||||
| Ток % | `motor/control/tmc/current_percent` | `50` |
|
||||
| Микрошаг | `motor/control/tmc/microsteps` | `16` |
|
||||
| Скорость RPM | `motor/control/rpm` | `-200` (у вас направление с минусом; диапазон примерно −1000…1000) |
|
||||
| Серво угол | `servo/control/{N}/angle` | `0`…`180` |
|
||||
| Серво вкл | `servo/control/{N}/enable` | `on` |
|
||||
|
||||
Телеметрия: `motor/feedback/#`, `servo/+/feedback/#`.
|
||||
|
||||
Ручной тест ленты (если `mosquitto_pub` есть):
|
||||
|
||||
```bash
|
||||
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/driver -m on
|
||||
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/tmc/enable -m on
|
||||
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/rpm -m -200
|
||||
# стоп:
|
||||
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/rpm -m 0
|
||||
```
|
||||
|
||||
Или откройте `backend_control/gui.py` (`MQTT_BROKER = "192.168.31.225"`).
|
||||
|
||||
## 3. Камера RealSense D415
|
||||
|
||||
1. USB3 (скорость 5000M в `lsusb -t`).
|
||||
2. Камера **сверху** над лентой, **0.5–1.5 м**.
|
||||
3. В `config.yaml` задайте `belt_distance_mm` = реальная высота в мм.
|
||||
|
||||
```bash
|
||||
lsusb | grep -i RealSense
|
||||
v4l2-ctl --list-devices
|
||||
```
|
||||
|
||||
## 4. Запуск vision (Orange PI)
|
||||
|
||||
```bash
|
||||
cd ~/workdir/vision_classifier
|
||||
|
||||
# один кадр без железа
|
||||
./run.sh --once --no-mqtt --no-motor
|
||||
|
||||
# превью без движения (JPEG обновляются на диске)
|
||||
./run.sh --preview --dry-route --no-motor
|
||||
# смотреть: debug_frames/live_color.jpg и live_depth.jpg
|
||||
|
||||
# полный контур: камера → класс → MQTT → мотор + серво
|
||||
PYTHONUNBUFFERED=1 ./run.sh --preview
|
||||
# стоп: Ctrl+C (rpm сбросится в 0)
|
||||
```
|
||||
|
||||
Превью **не открывает окна** (у вас OpenCV headless) — пишет файлы:
|
||||
|
||||
- `debug_frames/live_color.jpg`
|
||||
- `debug_frames/live_depth.jpg`
|
||||
|
||||
Откройте их в Cursor/VS Code или:
|
||||
|
||||
```bash
|
||||
eog debug_frames/live_color.jpg &
|
||||
# или периодически обновлять в браузере/просмотрщике
|
||||
```
|
||||
|
||||
## 5. Настройка зон под вашу механику
|
||||
|
||||
В `config.yaml` → `routing.zones` и `motor`:
|
||||
|
||||
```yaml
|
||||
motor:
|
||||
enabled: true
|
||||
rpm: -200 # у вас направление ленты — отрицательный RPM
|
||||
|
||||
mqtt:
|
||||
broker: "127.0.0.1" # mosquitto на Orange PI; ESP и SCADA → 192.168.31.225
|
||||
|
||||
routing:
|
||||
zones:
|
||||
B: { servo: 0, idle_angle: 0, divert_angle: 0, hold_ms: 500 }
|
||||
C: { servo: 1, idle_angle: 0, divert_angle: 90, hold_ms: 800 }
|
||||
D: { servo: 2, idle_angle: 0, divert_angle: 90, hold_ms: 800 }
|
||||
```
|
||||
Подставьте **номера каналов PCA9685** и углы, которые уже проверены в SCADA.
|
||||
|
||||
Логика ТЗ:
|
||||
|
||||
| Класс | Зона | Действие |
|
||||
|-------|------|----------|
|
||||
| Подходит | B | лента едет, серво idle |
|
||||
| Не по габаритам | C | серво C на `divert_angle`, потом idle |
|
||||
| Круг (доупаковка) | D | серво D на `divert_angle`, потом idle |
|
||||
|
||||
## 6. SCADA параллельно
|
||||
|
||||
Можно держать GUI открытым для телеметрии:
|
||||
|
||||
```bash
|
||||
cd ~/workdir/backend_control
|
||||
# в gui.py: MQTT_BROKER = "192.168.31.225" (или 127.0.0.1 с OPI)
|
||||
source testing/bin/activate # или свой venv
|
||||
python gui.py
|
||||
```
|
||||
|
||||
Vision и SCADA могут работать вместе на одном брокере.
|
||||
|
||||
## 7. Docker (опционально)
|
||||
|
||||
```bash
|
||||
cd ~/workdir/vision_classifier
|
||||
sudo docker compose up -d --build
|
||||
sudo docker compose logs -f
|
||||
```
|
||||
|
||||
## 8. Типичные проблемы
|
||||
|
||||
| Симптом | Что сделать |
|
||||
|---------|-------------|
|
||||
| `cv2.imshow` / GTK error | уже исправлено: `--preview` пишет JPEG |
|
||||
| `mqtt timed out` | брокер на OPI → `broker: "127.0.0.1"`; ESP → `192.168.31.225` |
|
||||
| ESP не реагирует | одна Wi‑Fi сеть, брокер `192.168.31.225:1883`, логин `test`/`1234` |
|
||||
| depth valid < 30% | USB3, высота 0.5–1.5 м, не чёрная лента в упор |
|
||||
| серво не те | поправьте `routing.zones.*.servo` под SCADA |
|
||||
| лента не едет | `motor.enabled: true`, проверьте SCADA rpm вручную |
|
||||
|
||||
## 9. Чеклист «всё связано»
|
||||
|
||||
1. [ ] `mosquitto` Up (`docker ps`)
|
||||
2. [ ] ESP32 в Wi‑Fi, в MQTT Explorer видны `motor/feedback/*`
|
||||
3. [ ] Из SCADA крутится шаговик и двигаются серво
|
||||
4. [ ] `config.yaml`: `mqtt.broker` = тот же брокер
|
||||
5. [ ] `belt_distance_mm` и углы серво подогнаны
|
||||
6. [ ] `./run.sh` печатает зону B/C/D и в Explorer появляются `vision/feedback/*`
|
||||
7. [ ] При объекте срабатывает нужный серво, лента крутится
|
||||
61
vision_classifier/README.md
Normal file
61
vision_classifier/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Vision Classifier — трек 3 (RealSense D415 на Orange PI)
|
||||
|
||||
## Правила ТЗ (фиксированные)
|
||||
|
||||
1. Габариты строго `>10×10×10` и `<450×320×320` мм → иначе **C**
|
||||
2. Иначе если `r_in/r_out ≥ 0.8` (вид сверху **или** ≥2 поперечных среза) → **D**
|
||||
3. Иначе → **B**
|
||||
|
||||
Стабилизация: медиана 12 кадров + голосование ≥2/3 окна → **LOCK**.
|
||||
После LOCK зона не меняется, пока объект не убрали. Без «достройки» круга на коробках.
|
||||
|
||||
**Пограничные случаи:** если консенсуса нет `uncertain_after_frames` кадров
|
||||
(ratio у порога 0.8, сторона у лимита 450/320) — товар помечается **«неуверенно»**
|
||||
и уходит в безопасную зону (`uncertain_fallback_zone: C`), а не в основной поток.
|
||||
Все LOCK-решения пишутся в `logs/decisions.jsonl` (метрики для отчёта: зоны,
|
||||
габариты, ratio, доля неуверенных).
|
||||
|
||||
## Калибровка (обязательно перед замером габаритов!)
|
||||
|
||||
Габариты считаются через фокусное `fx/fy`. Дефолт `670` — расчёт по спецификации
|
||||
D415 @ 640×480 (старое значение 430 завышало размеры в ~1.56 раза). Уточнить под
|
||||
свою камеру по коробке известного размера (рулеткой, высота коробки ≥ 30 мм):
|
||||
|
||||
```bash
|
||||
.venv/bin/python calibrate.py --length 300 --width 200
|
||||
```
|
||||
|
||||
Скрипт сам замерит высоту ленты и запишет `fx/fy` + `belt_distance_mm` в `config.yaml`.
|
||||
|
||||
## Фоновая карта глубины
|
||||
|
||||
Сегментация умеет работать относительно карты фона (а не одной высоты):
|
||||
товар не сливается с платформой/бортами накопителя, а предмет, лежащий
|
||||
на другом предмете, выделяется отдельно.
|
||||
|
||||
- `main.py`: `use_background_map: true` в конфиге — карта снимается при старте
|
||||
(лента должна быть **пустой**);
|
||||
- `demo.py`: кнопка **«Авто-высота»** снимает карту фона (уберите объекты);
|
||||
ручное движение ползунка высоты сбрасывает карту.
|
||||
|
||||
## Демо
|
||||
|
||||
```bash
|
||||
cd vision_classifier
|
||||
./demo.sh
|
||||
```
|
||||
|
||||
Браузер: **http://127.0.0.1:8080/** (Ctrl+F5 после перезапуска)
|
||||
|
||||
Ползунки сверху: высота ленты, уверенность, мин. высота, порог круга, площадь.
|
||||
**Авто-высота** — калибровка по пустой ленте. Выход: `Ctrl+C`.
|
||||
|
||||
Проверка без камеры:
|
||||
|
||||
```bash
|
||||
.venv/bin/python test_classify.py
|
||||
.venv/bin/python test_geometry.py
|
||||
```
|
||||
|
||||
**Полный запуск / стоп:** [START.md](START.md)
|
||||
Боевой режим (MQTT + шаговик + серво): [INTEGRATION.md](INTEGRATION.md)
|
||||
140
vision_classifier/START.md
Normal file
140
vision_classifier/START.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Полный запуск системы (трек 3)
|
||||
|
||||
Orange PI **192.168.31.225** · MQTT `test` / `1234` · RPM ленты **отрицательный** (`-200`)
|
||||
|
||||
---
|
||||
|
||||
## Стоп (сначала, если что-то уже крутится)
|
||||
|
||||
```bash
|
||||
# 1) убить vision (иначе снова поднимет ленту)
|
||||
pkill -f 'main.py' || true
|
||||
|
||||
# 2) остановить мотор
|
||||
cd ~/workdir/vision_classifier
|
||||
.venv/bin/python - <<'PY'
|
||||
from paho.mqtt.client import Client, CallbackAPIVersion
|
||||
import time
|
||||
c = Client(CallbackAPIVersion.VERSION2, client_id='stop')
|
||||
c.username_pw_set('test', '1234')
|
||||
c.connect('127.0.0.1', 1883, 60)
|
||||
c.loop_start(); time.sleep(0.3)
|
||||
c.publish('motor/control/rpm', '0', qos=1)
|
||||
time.sleep(0.3); c.loop_stop(); c.disconnect()
|
||||
print('rpm=0')
|
||||
PY
|
||||
```
|
||||
|
||||
Или `Ctrl+C` в терминале `main.py` — при выходе сам шлёт `rpm=0`.
|
||||
|
||||
---
|
||||
|
||||
## Чеклист перед стартом
|
||||
|
||||
1. **Питание** конвейера / драйвера / ESP32 включено.
|
||||
2. **RealSense D415** в USB3 (`lsusb | grep -i RealSense`).
|
||||
3. **Mosquitto** на Orange PI:
|
||||
```bash
|
||||
docker ps | grep mosquitto
|
||||
# должно быть 0.0.0.0:1883
|
||||
```
|
||||
4. **ESP32** в той же сети, в прошивке `MQTT_SERVER = "192.168.31.225"` (уже так).
|
||||
5. Лента под камерой **пустая** (пока не стартовали фон/превью).
|
||||
|
||||
---
|
||||
|
||||
## Запуск — полный контур (камера → B/C/D → MQTT → лента + серво)
|
||||
|
||||
```bash
|
||||
cd ~/workdir/vision_classifier
|
||||
|
||||
# превью JPEG + мотор + серво (рекомендуется)
|
||||
PYTHONUNBUFFERED=1 ./run.sh --preview
|
||||
```
|
||||
|
||||
Что должно появиться в логе:
|
||||
|
||||
```
|
||||
[camera] RGB: /dev/video4
|
||||
[camera] depth OK, valid≈…
|
||||
[mqtt] подключено к 127.0.0.1:1883
|
||||
```
|
||||
|
||||
После старта vision сам включает драйвер и крутит ленту с `motor.rpm: -200` из `config.yaml`.
|
||||
|
||||
### Куда смотреть во время работы
|
||||
|
||||
| Что | Где |
|
||||
|-----|-----|
|
||||
| Кадр RGB | `debug_frames/live_color.jpg` |
|
||||
| Depth | `debug_frames/live_depth.jpg` |
|
||||
| Решения LOCK | stdout + `logs/decisions.jsonl` |
|
||||
| MQTT трафик | http://192.168.31.225:3000 (MQTT Explorer) |
|
||||
| Зоны vision | топики `vision/feedback/*` |
|
||||
|
||||
Хвост логов:
|
||||
|
||||
```bash
|
||||
tail -f logs/decisions.jsonl
|
||||
# или фильтр по LOCK:
|
||||
tail -f logs/decisions.jsonl | while read l; do echo "$l" | .venv/bin/python -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('ts'), 'LOCK', d.get('zone'), d.get('dims_mm'), d.get('circle_ratio'), d.get('reason',''))"; done
|
||||
```
|
||||
|
||||
### Зоны
|
||||
|
||||
| LOCK | Смысл | Серво (config) |
|
||||
|------|--------|----------------|
|
||||
| **B** | подходит | servo 0 idle |
|
||||
| **C** | габариты вне 10…450×320×320 | servo 1 → 90° |
|
||||
| **D** | круг `ratio ≥ 0.8` | servo 2 → 90° |
|
||||
|
||||
Кладите один объект в поле камеры, ждите строку `LOCK B/C/D`.
|
||||
|
||||
---
|
||||
|
||||
## Запуск без движения ленты (только зрение)
|
||||
|
||||
```bash
|
||||
cd ~/workdir/vision_classifier
|
||||
PYTHONUNBUFFERED=1 ./run.sh --preview --dry-route --no-motor --no-mqtt
|
||||
```
|
||||
|
||||
JPEG всё равно обновляются в `debug_frames/`.
|
||||
|
||||
---
|
||||
|
||||
## Ручной тест ленты (без vision)
|
||||
|
||||
```bash
|
||||
# нужен mosquitto_pub или python из .venv
|
||||
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/driver -m on
|
||||
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/tmc/enable -m on
|
||||
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/rpm -m -200
|
||||
# стоп:
|
||||
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/rpm -m 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SCADA (опционально, параллельно)
|
||||
|
||||
```bash
|
||||
cd ~/workdir/backend_control
|
||||
# MQTT_BROKER = "192.168.31.225" или 127.0.0.1
|
||||
python gui.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Если что-то не едет
|
||||
|
||||
| Проблема | Действие |
|
||||
|----------|----------|
|
||||
| Камера занята | `pkill -f main.py`; `fuser /dev/video0` |
|
||||
| MQTT не коннектится | `docker ps \| grep mosquitto`, логин `test`/`1234` |
|
||||
| ESP молчит | одна Wi‑Fi сеть, брокер **192.168.31.225:1883** |
|
||||
| Лента в другую сторону | в `config.yaml` → `motor.rpm: -200` (минус обязателен) |
|
||||
| Ложные LOCK на пустой ленте | уберите мусор/блики; при необходимости поднимите `min_object_height_mm` |
|
||||
| Серво не те каналы | `routing.zones` в `config.yaml` |
|
||||
|
||||
Подробности сети/топиков: [INTEGRATION.md](INTEGRATION.md). Алгоритм: [README.md](README.md).
|
||||
139
vision_classifier/align_color.py
Normal file
139
vision_classifier/align_color.py
Normal file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Автоподбор совмещения RGB↔depth для демо (сенсоры D415 разнесены).
|
||||
|
||||
Положите на ленту коробку с чёткими краями и запустите:
|
||||
.venv/bin/python align_color.py
|
||||
|
||||
Скрипт ищет сдвиг (dx, dy) и масштаб цветного кадра, при которых края
|
||||
на RGB совпадают с краями на карте глубины, и пишет результат в config.yaml.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from camera import RealSenseV4L2
|
||||
from demo_hud import align_color
|
||||
|
||||
|
||||
def _edges_depth(depth_mm: np.ndarray) -> np.ndarray:
|
||||
d = depth_mm.astype(np.float32)
|
||||
d = cv2.medianBlur(d.astype(np.uint16), 5).astype(np.float32)
|
||||
valid = d > 0
|
||||
if valid.sum() < 1000:
|
||||
return np.zeros(depth_mm.shape, np.uint8)
|
||||
lo, hi = np.percentile(d[valid], [2, 98])
|
||||
norm = np.clip((d - lo) / max(hi - lo, 1.0) * 255.0, 0, 255).astype(np.uint8)
|
||||
return cv2.Canny(norm, 30, 90)
|
||||
|
||||
|
||||
def _edges_color(color_bgr: np.ndarray) -> np.ndarray:
|
||||
gray = cv2.cvtColor(color_bgr, cv2.COLOR_BGR2GRAY)
|
||||
# тёмные сцены: выравниваем контраст перед Canny
|
||||
gray = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)).apply(gray)
|
||||
gray = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||
return cv2.Canny(gray, 40, 120)
|
||||
|
||||
|
||||
def _score(color_edges: np.ndarray, depth_band: np.ndarray, dx: float, dy: float, s: float) -> float:
|
||||
warped = align_color(color_edges[..., None].repeat(3, axis=2), dx, dy, s)[..., 0]
|
||||
return float(np.count_nonzero((warped > 0) & (depth_band > 0)))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Совмещение RGB и depth")
|
||||
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg_path = Path(args.config)
|
||||
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
cam_cfg = cfg["camera"]
|
||||
|
||||
print("[align] открываю камеру… на ленте должна лежать коробка с чёткими краями")
|
||||
cam = RealSenseV4L2(
|
||||
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
|
||||
color_device=cam_cfg.get("color_device", "/dev/video4"),
|
||||
width=int(cam_cfg.get("width", 640)),
|
||||
height=int(cam_cfg.get("height", 480)),
|
||||
fps=int(cam_cfg.get("fps", 30)),
|
||||
use_color=True,
|
||||
)
|
||||
try:
|
||||
# прогрев RGB: первые кадры бывают пустыми, плюс автоэкспозиция
|
||||
for _ in range(30):
|
||||
cam.read()
|
||||
time.sleep(0.05)
|
||||
depth_acc, color_acc = [], []
|
||||
for _ in range(15):
|
||||
pair = cam.read()
|
||||
if pair is not None and not pair.color_is_depth_preview:
|
||||
depth_acc.append(pair.depth_mm.astype(np.float32))
|
||||
color_acc.append(pair.color_bgr.astype(np.float32))
|
||||
time.sleep(0.06)
|
||||
if len(color_acc) < 3:
|
||||
print("[align] RGB не читается — проверьте use_color/USB")
|
||||
return 1
|
||||
depth = np.median(np.stack(depth_acc), axis=0).astype(np.uint16)
|
||||
color = np.clip(np.mean(np.stack(color_acc), axis=0), 0, 255).astype(np.uint8)
|
||||
|
||||
de = _edges_depth(depth)
|
||||
if np.count_nonzero(de) < 500:
|
||||
print("[align] мало краёв на depth — положите коробку в центр кадра")
|
||||
return 1
|
||||
band = cv2.dilate(de, cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)))
|
||||
ce = _edges_color(color)
|
||||
|
||||
# грубый перебор → уточнение
|
||||
best = (0.0, 0.0, 1.0)
|
||||
best_s = -1.0
|
||||
for s in np.arange(0.90, 1.16, 0.05):
|
||||
for dx in range(-80, 81, 8):
|
||||
for dy in range(-60, 61, 8):
|
||||
sc = _score(ce, band, dx, dy, float(s))
|
||||
if sc > best_s:
|
||||
best_s, best = sc, (float(dx), float(dy), float(s))
|
||||
bdx, bdy, bs = best
|
||||
for s in np.arange(bs - 0.04, bs + 0.045, 0.01):
|
||||
for dx in np.arange(bdx - 8, bdx + 9, 2):
|
||||
for dy in np.arange(bdy - 8, bdy + 9, 2):
|
||||
sc = _score(ce, band, float(dx), float(dy), float(s))
|
||||
if sc > best_s:
|
||||
best_s, best = sc, (float(dx), float(dy), float(s))
|
||||
|
||||
base = _score(ce, band, 0, 0, 1.0)
|
||||
dx, dy, s = best
|
||||
print(f"[align] лучшее совмещение: dx={dx:.0f} dy={dy:.0f} scale={s:.2f} "
|
||||
f"(совпадение краёв {best_s:.0f} против {base:.0f} без коррекции)")
|
||||
|
||||
text = cfg_path.read_text(encoding="utf-8")
|
||||
text = re.sub(r"(?m)^(\s*color_dx:\s*)-?[\d.]+", rf"\g<1>{dx:.1f}", text)
|
||||
text = re.sub(r"(?m)^(\s*color_dy:\s*)-?[\d.]+", rf"\g<1>{dy:.1f}", text)
|
||||
text = re.sub(r"(?m)^(\s*color_scale:\s*)-?[\d.]+", rf"\g<1>{s:.3f}", text)
|
||||
cfg_path.write_text(text, encoding="utf-8")
|
||||
print(f"[align] записано в {cfg_path}")
|
||||
|
||||
# контрольная картинка
|
||||
out = Path("debug_frames"); out.mkdir(exist_ok=True)
|
||||
from camera import depth_colormap
|
||||
aligned = align_color(color, dx, dy, s)
|
||||
vis = cv2.addWeighted(aligned, 0.6, depth_colormap(depth), 0.4, 0)
|
||||
cv2.imwrite(str(out / "align_check.jpg"), vis)
|
||||
print(f"[align] проверка: debug_frames/align_check.jpg")
|
||||
finally:
|
||||
cam.release()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
182
vision_classifier/calibrate.py
Normal file
182
vision_classifier/calibrate.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Калибровка камеры для точных габаритов (правила ТЗ: >10×10×10, <450×320×320 мм).
|
||||
|
||||
Два шага:
|
||||
1) пустая лента → высота belt_distance_mm;
|
||||
2) коробка известного размера в центре → фокусное fx=fy.
|
||||
|
||||
Запуск (пример для коробки 300×200 мм, высотой ≥ 30 мм):
|
||||
.venv/bin/python calibrate.py --length 300 --width 200
|
||||
|
||||
Результат пишется прямо в config.yaml (fx, fy, belt_distance_mm).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from camera import RealSenseV4L2
|
||||
from measure import segment_object
|
||||
|
||||
|
||||
def _wait_key(prompt: str) -> None:
|
||||
"""Ждёт одиночное нажатие: 1 — продолжить, q — выйти (Enter не нужен)."""
|
||||
print(prompt + " [1 — продолжить, q — выйти]", flush=True)
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
# stdin не терминал (пайп/IDE) — читаем строку
|
||||
line = sys.stdin.readline().strip().lower()
|
||||
if line.startswith("q"):
|
||||
raise KeyboardInterrupt
|
||||
return
|
||||
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
while True:
|
||||
ch = sys.stdin.read(1)
|
||||
if ch in ("1", "\r", "\n"): # Enter тоже принимаем на всякий случай
|
||||
return
|
||||
if ch in ("q", "Q", "\x03"): # q или Ctrl+C
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||
|
||||
|
||||
def _collect_belt(cam: RealSenseV4L2, samples: int = 25) -> float:
|
||||
vals = []
|
||||
for _ in range(samples):
|
||||
pair = cam.read()
|
||||
if pair is None:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
d = pair.depth_mm
|
||||
h, w = d.shape
|
||||
roi = d[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4]
|
||||
valid = roi[(roi > 200) & (roi < 4000)]
|
||||
if valid.size > 100:
|
||||
vals.append(float(np.median(valid)))
|
||||
time.sleep(0.04)
|
||||
if not vals:
|
||||
raise RuntimeError("Не вижу ленту: проверьте, что камера на 0.5–1.5 м над поверхностью")
|
||||
return float(np.median(vals))
|
||||
|
||||
|
||||
def _collect_focal(
|
||||
cam: RealSenseV4L2,
|
||||
belt_mm: float,
|
||||
known_length_mm: float,
|
||||
known_width_mm: float,
|
||||
samples: int = 40,
|
||||
) -> float:
|
||||
"""fx=fy по площади minAreaRect в пикселях: f = z * sqrt(S_px / S_mm)."""
|
||||
focals = []
|
||||
for _ in range(samples):
|
||||
pair = cam.read()
|
||||
if pair is None:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
seg = segment_object(pair.depth_mm, belt_distance_mm=belt_mm, min_area_px=400)
|
||||
if seg is None:
|
||||
time.sleep(0.04)
|
||||
continue
|
||||
mask, contour = seg
|
||||
ys, xs = np.where(mask > 0)
|
||||
z = pair.depth_mm[ys, xs].astype(np.float32)
|
||||
z = z[z > 0]
|
||||
if z.size < 100:
|
||||
continue
|
||||
z_med = float(np.median(z))
|
||||
|
||||
rect = cv2.minAreaRect(contour)
|
||||
pw, ph = rect[1]
|
||||
if pw < 10 or ph < 10:
|
||||
continue
|
||||
f = z_med * float(np.sqrt((pw * ph) / (known_length_mm * known_width_mm)))
|
||||
focals.append(f)
|
||||
time.sleep(0.04)
|
||||
if len(focals) < 10:
|
||||
raise RuntimeError(
|
||||
f"Стабильно вижу коробку только в {len(focals)} кадрах из {samples}. "
|
||||
"Коробка должна быть высотой ≥ 30 мм и лежать в центре кадра."
|
||||
)
|
||||
return float(np.median(focals))
|
||||
|
||||
|
||||
def _patch_config(path: Path, fx: float, belt_mm: float) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
text = re.sub(r"(?m)^(\s*fx:\s*)[\d.]+", rf"\g<1>{fx:.1f}", text)
|
||||
text = re.sub(r"(?m)^(\s*fy:\s*)[\d.]+", rf"\g<1>{fx:.1f}", text)
|
||||
text = re.sub(r"(?m)^(belt_distance_mm:\s*)[\d.]+", rf"\g<1>{belt_mm:.0f}", text)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Калибровка fx/fy и высоты ленты")
|
||||
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
|
||||
parser.add_argument("--length", type=float, required=True, help="Длина коробки, мм (рулеткой)")
|
||||
parser.add_argument("--width", type=float, required=True, help="Ширина коробки, мм (рулеткой)")
|
||||
parser.add_argument("--yes", action="store_true", help="Не ждать Enter (сцена уже готова на каждом шаге)")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg_path = Path(args.config)
|
||||
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
cam_cfg = cfg["camera"]
|
||||
|
||||
print("[calib] открываю RealSense D415…")
|
||||
cam = RealSenseV4L2(
|
||||
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
|
||||
color_device=cam_cfg.get("color_device", "/dev/video4"),
|
||||
width=int(cam_cfg.get("width", 640)),
|
||||
height=int(cam_cfg.get("height", 480)),
|
||||
fps=int(cam_cfg.get("fps", 30)),
|
||||
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
|
||||
use_color=False,
|
||||
)
|
||||
try:
|
||||
if not args.yes:
|
||||
_wait_key("[calib] Шаг 1/2: УБЕРИТЕ всё с ленты")
|
||||
belt_mm = _collect_belt(cam)
|
||||
print(f"[calib] высота до ленты: {belt_mm:.0f} мм")
|
||||
|
||||
if not args.yes:
|
||||
_wait_key(
|
||||
f"[calib] Шаг 2/2: положите коробку {args.length:.0f}×{args.width:.0f} мм "
|
||||
"в центр кадра"
|
||||
)
|
||||
time.sleep(1.0)
|
||||
fx = _collect_focal(cam, belt_mm, args.length, args.width)
|
||||
old_fx = float(cam_cfg.get("fx", 0))
|
||||
print(f"[calib] фокусное fx=fy: {fx:.1f} (было {old_fx:.1f})")
|
||||
if old_fx > 0:
|
||||
k = fx / old_fx
|
||||
print(f"[calib] габариты со старым fx были завышены/занижены в {k:.2f} раза")
|
||||
|
||||
_patch_config(cfg_path, fx, belt_mm)
|
||||
print(f"[calib] записано в {cfg_path}: fx=fy={fx:.1f}, belt_distance_mm={belt_mm:.0f}")
|
||||
print("[calib] проверьте: .venv/bin/python demo.py — размеры LWH должны совпадать с рулеткой")
|
||||
except KeyboardInterrupt:
|
||||
print("\n[calib] отменено, config.yaml не изменён")
|
||||
return 1
|
||||
finally:
|
||||
cam.release()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
373
vision_classifier/camera.py
Normal file
373
vision_classifier/camera.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""Захват Depth (+опционально Color) с Intel RealSense D415 через V4L2/ffmpeg.
|
||||
|
||||
Классификация по ТЗ опирается на depth (габариты + круг в сечении).
|
||||
RGB у D415 через сырой V4L2 часто пустой без librealsense —
|
||||
тогда для превью используется colorize(depth).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Union
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class FramePair:
|
||||
color_bgr: np.ndarray
|
||||
depth_mm: np.ndarray # uint16, миллиметры
|
||||
timestamp_ms: float
|
||||
color_is_depth_preview: bool = False
|
||||
|
||||
|
||||
def _device_path(device: Union[str, int]) -> str:
|
||||
if isinstance(device, int) or str(device).isdigit():
|
||||
return f"/dev/video{int(device)}"
|
||||
return str(device)
|
||||
|
||||
|
||||
def _v4l2_index(device: Union[str, int]) -> int:
|
||||
if isinstance(device, int):
|
||||
return device
|
||||
s = str(device).strip()
|
||||
if s.isdigit():
|
||||
return int(s)
|
||||
if "video" in s:
|
||||
return int(s.rsplit("video", 1)[-1])
|
||||
raise ValueError(f"Некорректный V4L2 device: {device}")
|
||||
|
||||
|
||||
def find_realsense_color_device(preferred: Union[str, int, None] = None) -> Optional[str]:
|
||||
"""Найти RGB-ноду D415 (YUYV). Номера /dev/videoN плавают после переподключения."""
|
||||
import glob
|
||||
import os
|
||||
|
||||
def _formats(path: str) -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["v4l2-ctl", "-d", path, "--list-formats-ext"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
preferred_path = _device_path(preferred) if preferred is not None else ""
|
||||
scored: list[tuple[int, str]] = []
|
||||
for path in sorted(glob.glob("/dev/video*")):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
fmt = _formats(path)
|
||||
if "Z16" in fmt or "'GREY'" in fmt or "Greyscale" in fmt:
|
||||
continue
|
||||
score = 2 if ("YUYV" in fmt or "MJPG" in fmt or "Motion-JPEG" in fmt) else 0
|
||||
if path == preferred_path:
|
||||
score += 5
|
||||
scored.append((score, path))
|
||||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||||
|
||||
for _, path in scored:
|
||||
try:
|
||||
idx = _v4l2_index(path)
|
||||
except ValueError:
|
||||
continue
|
||||
cap = cv2.VideoCapture(idx, cv2.CAP_V4L2)
|
||||
if not cap.isOpened():
|
||||
continue
|
||||
ok_frame = None
|
||||
for _ in range(12):
|
||||
ok, frame = cap.read()
|
||||
if not ok or frame is None:
|
||||
continue
|
||||
if frame.ndim == 2:
|
||||
break
|
||||
if frame.ndim == 3 and frame.shape[2] == 2:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_YUY2)
|
||||
if frame.ndim == 3 and float(np.mean(frame)) > 8.0 and float(np.std(frame)) > 5.0:
|
||||
ok_frame = frame
|
||||
break
|
||||
cap.release()
|
||||
if ok_frame is not None:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def fill_depth_holes(depth_mm: np.ndarray, ksize: int = 5) -> np.ndarray:
|
||||
"""Простое заполнение дыр в depth."""
|
||||
d = depth_mm.copy()
|
||||
mask = (d > 0).astype(np.uint8) * 255
|
||||
if mask.mean() < 1:
|
||||
return d
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize))
|
||||
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||
holes = ((closed > 0) & (d == 0)).astype(np.uint8) * 255
|
||||
if holes.any():
|
||||
scale = max(float(d.max()), 1.0)
|
||||
img8 = np.clip(d.astype(np.float32) / scale * 255.0, 0, 255).astype(np.uint8)
|
||||
filled8 = cv2.inpaint(img8, holes, 3, cv2.INPAINT_TELEA)
|
||||
filled = (filled8.astype(np.float32) / 255.0 * scale).astype(np.uint16)
|
||||
d[holes > 0] = filled[holes > 0]
|
||||
med = cv2.medianBlur(d, 3)
|
||||
valid = d > 0
|
||||
d[valid] = med[valid]
|
||||
return d
|
||||
|
||||
|
||||
def depth_colormap(depth_mm: np.ndarray, max_mm: Optional[int] = None) -> np.ndarray:
|
||||
valid = depth_mm[(depth_mm > 0) & (depth_mm < 10000)]
|
||||
if max_mm is None:
|
||||
max_mm = int(np.percentile(valid, 95)) if valid.size else 2000
|
||||
max_mm = max(max_mm, 500)
|
||||
clipped = np.clip(depth_mm.astype(np.float32), 0, max_mm)
|
||||
norm = np.zeros_like(clipped, dtype=np.uint8)
|
||||
mask = depth_mm > 0
|
||||
norm[mask] = (clipped[mask] / max_mm * 255.0).astype(np.uint8)
|
||||
return cv2.applyColorMap(norm, cv2.COLORMAP_JET)
|
||||
|
||||
|
||||
class RealSenseV4L2:
|
||||
"""D415: depth=/dev/video0 (Z16 gray16le). Color опционален."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
depth_device: Union[str, int] = "/dev/video0",
|
||||
color_device: Union[str, int] = "/dev/video4",
|
||||
width: int = 640,
|
||||
height: int = 480,
|
||||
fps: int = 30,
|
||||
depth_scale_mm: float = 1.0,
|
||||
use_color: bool = True,
|
||||
) -> None:
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise RuntimeError("Нужен ffmpeg для чтения depth Z16 с RealSense")
|
||||
|
||||
self.depth_scale_mm = float(depth_scale_mm)
|
||||
self.width = int(width)
|
||||
self.height = int(height)
|
||||
self.fps = int(fps)
|
||||
self.use_color = bool(use_color)
|
||||
self._frame_bytes = self.width * self.height * 2
|
||||
self.color_cap = None
|
||||
self._depth_path = _device_path(depth_device)
|
||||
self._lock = threading.Lock()
|
||||
self._latest: Optional[np.ndarray] = None
|
||||
self._stop = threading.Event()
|
||||
self._ff: Optional[subprocess.Popen] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
self._start_depth_worker()
|
||||
|
||||
if self.use_color:
|
||||
found = find_realsense_color_device(color_device)
|
||||
if found is None:
|
||||
print(f"[camera] RGB не найден (искали {color_device}) — в вебе будет colorize(depth)")
|
||||
self.color_cap = None
|
||||
else:
|
||||
if _device_path(found) != _device_path(color_device):
|
||||
print(f"[camera] RGB: {found} (в конфиге было {color_device})")
|
||||
else:
|
||||
print(f"[camera] RGB: {found}")
|
||||
color_idx = _v4l2_index(found)
|
||||
self.color_cap = cv2.VideoCapture(color_idx, cv2.CAP_V4L2)
|
||||
if self.color_cap.isOpened():
|
||||
self.color_cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
|
||||
self.color_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
|
||||
self.color_cap.set(cv2.CAP_PROP_FPS, self.fps)
|
||||
self.color_cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
|
||||
# прогрев автоэкспозиции — иначе первые кадры чёрные/зелёные
|
||||
for _ in range(20):
|
||||
self.color_cap.read()
|
||||
else:
|
||||
print("[camera] RGB VideoCapture не открылся")
|
||||
self.color_cap = None
|
||||
|
||||
# Ждём первый кадр
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
with self._lock:
|
||||
if self._latest is not None:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
self.release()
|
||||
raise RuntimeError(
|
||||
f"Не удалось читать depth с {self._depth_path}. "
|
||||
"Проверьте USB3, что камера не занята другим процессом."
|
||||
)
|
||||
|
||||
valid_pct = float(((self._latest > 0) & (self._latest < 5000)).mean() * 100)
|
||||
print(f"[camera] depth OK, valid≈{valid_pct:.1f}% (лучше >30%; высота камеры 0.5–1.5 м)")
|
||||
|
||||
def _start_depth_worker(self) -> None:
|
||||
self._ff = subprocess.Popen(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-fflags",
|
||||
"nobuffer",
|
||||
"-flags",
|
||||
"low_delay",
|
||||
"-f",
|
||||
"v4l2",
|
||||
"-video_size",
|
||||
f"{self.width}x{self.height}",
|
||||
"-framerate",
|
||||
str(self.fps),
|
||||
"-pixel_format",
|
||||
"gray16le",
|
||||
"-i",
|
||||
self._depth_path,
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"gray16le",
|
||||
"-",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=self._frame_bytes * 8,
|
||||
)
|
||||
self._thread = threading.Thread(target=self._depth_loop, name="rs-depth", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _depth_loop(self) -> None:
|
||||
assert self._ff is not None and self._ff.stdout is not None
|
||||
while not self._stop.is_set():
|
||||
raw = self._ff.stdout.read(self._frame_bytes)
|
||||
if not raw or len(raw) != self._frame_bytes:
|
||||
if self._ff.poll() is not None:
|
||||
break
|
||||
continue
|
||||
depth = np.frombuffer(raw, dtype="<u2").reshape(self.height, self.width).copy()
|
||||
depth[depth == 65535] = 0
|
||||
if self.depth_scale_mm != 1.0:
|
||||
depth = np.clip(depth.astype(np.float32) * self.depth_scale_mm, 0, 65535).astype(np.uint16)
|
||||
depth = fill_depth_holes(depth)
|
||||
with self._lock:
|
||||
self._latest = depth
|
||||
|
||||
def _read_color(self, depth_mm: np.ndarray) -> tuple[np.ndarray, bool]:
|
||||
if self.color_cap is not None:
|
||||
ok, color = self.color_cap.read()
|
||||
if ok and color is not None:
|
||||
if color.shape[:2] != (self.height, self.width):
|
||||
color = cv2.resize(color, (self.width, self.height), interpolation=cv2.INTER_LINEAR)
|
||||
if color.ndim == 3 and color.shape[2] == 2:
|
||||
color = cv2.cvtColor(color, cv2.COLOR_YUV2BGR_YUY2)
|
||||
elif color.ndim == 2:
|
||||
color = cv2.cvtColor(color, cv2.COLOR_GRAY2BGR)
|
||||
# пустой YUYV-кадр после конвертации — ровный зелёный (mean>5,
|
||||
# но вариации нет) → проверяем и разброс пикселей
|
||||
if float(np.mean(color)) > 5.0 and float(np.std(color)) > 4.0:
|
||||
return color, False
|
||||
return depth_colormap(depth_mm), True
|
||||
|
||||
def read(self) -> Optional[FramePair]:
|
||||
with self._lock:
|
||||
depth = None if self._latest is None else self._latest.copy()
|
||||
if depth is None:
|
||||
return None
|
||||
color, is_preview = self._read_color(depth)
|
||||
return FramePair(
|
||||
color_bgr=color,
|
||||
depth_mm=depth,
|
||||
timestamp_ms=time.time() * 1000.0,
|
||||
color_is_depth_preview=is_preview,
|
||||
)
|
||||
|
||||
def capture_background(self, samples: int = 15) -> np.ndarray:
|
||||
"""Медианная карта глубины пустой сцены (лента + платформы/борта).
|
||||
|
||||
Позволяет сегментировать товар на неровном фоне и не сливать его
|
||||
с накопителем: объект = то, что ближе фона на min_object_height_mm.
|
||||
"""
|
||||
frames = []
|
||||
deadline = time.time() + 12.0
|
||||
while len(frames) < samples and time.time() < deadline:
|
||||
pair = self.read()
|
||||
if pair is not None:
|
||||
frames.append(pair.depth_mm.astype(np.float32))
|
||||
time.sleep(0.04)
|
||||
if len(frames) < max(3, samples // 3):
|
||||
raise RuntimeError("Не удалось накопить кадры для фоновой карты")
|
||||
stack = np.stack(frames)
|
||||
stack[stack <= 0] = np.nan
|
||||
bg = np.nanmedian(stack, axis=0)
|
||||
return np.nan_to_num(bg, nan=0.0).astype(np.uint16)
|
||||
|
||||
def capture_background_rgb(self, samples: int = 10) -> Optional[np.ndarray]:
|
||||
"""Усреднённый RGB-кадр пустой сцены — для детекции плоских товаров
|
||||
(телефон и т.п.), которые не видны в depth."""
|
||||
frames = []
|
||||
deadline = time.time() + 8.0
|
||||
while len(frames) < samples and time.time() < deadline:
|
||||
pair = self.read()
|
||||
if pair is not None and not pair.color_is_depth_preview:
|
||||
frames.append(pair.color_bgr.astype(np.float32))
|
||||
time.sleep(0.04)
|
||||
if len(frames) < 3:
|
||||
return None
|
||||
return np.clip(np.mean(np.stack(frames), axis=0), 0, 255).astype(np.uint8)
|
||||
|
||||
def estimate_belt_distance_mm(self, samples: int = 30) -> float:
|
||||
vals = []
|
||||
h, w = self.height, self.width
|
||||
rois = [
|
||||
(h // 2 - 40, h // 2 + 40, w // 2 - 60, w // 2 + 60),
|
||||
(h // 3 - 30, h // 3 + 30, w // 3 - 40, w // 3 + 40),
|
||||
(2 * h // 3 - 30, 2 * h // 3 + 30, 2 * w // 3 - 40, 2 * w // 3 + 40),
|
||||
(h // 4, 3 * h // 4, w // 4, 3 * w // 4),
|
||||
]
|
||||
for _ in range(samples):
|
||||
pair = self.read()
|
||||
if pair is None:
|
||||
time.sleep(0.03)
|
||||
continue
|
||||
for y0, y1, x0, x1 in rois:
|
||||
roi = pair.depth_mm[y0:y1, x0:x1]
|
||||
valid = roi[(roi > 200) & (roi < 4000)]
|
||||
if valid.size >= 50:
|
||||
vals.append(float(np.median(valid)))
|
||||
break
|
||||
else:
|
||||
valid = pair.depth_mm[(pair.depth_mm > 200) & (pair.depth_mm < 4000)]
|
||||
if valid.size >= 50:
|
||||
vals.append(float(np.median(valid)))
|
||||
time.sleep(0.03)
|
||||
if not vals:
|
||||
raise RuntimeError(
|
||||
"Не удалось оценить belt_distance_mm. "
|
||||
"Поставьте камеру на 0.5–1.5 м над лентой (USB3), задайте belt_distance_mm в config.yaml вручную."
|
||||
)
|
||||
return float(np.median(vals))
|
||||
|
||||
def release(self) -> None:
|
||||
self._stop.set()
|
||||
if self.color_cap is not None:
|
||||
self.color_cap.release()
|
||||
self.color_cap = None
|
||||
if self._ff is not None and self._ff.poll() is None:
|
||||
self._ff.terminate()
|
||||
try:
|
||||
self._ff.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._ff.kill()
|
||||
self._ff = None
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2)
|
||||
self._thread = None
|
||||
|
||||
def __enter__(self) -> "RealSenseV4L2":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args) -> None:
|
||||
self.release()
|
||||
147
vision_classifier/classify.py
Normal file
147
vision_classifier/classify.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Классификация строго по правилам ТЗ трека 3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Sequence, Tuple
|
||||
|
||||
from measure import ObjectMeasurement
|
||||
|
||||
|
||||
class Category(str, Enum):
|
||||
SUITABLE = "suitable" # Подходит для сортировки → B
|
||||
OVERSIZE = "oversize" # Не подходит по габаритам → C
|
||||
NEED_PACK = "need_pack" # Не подходит без доупаковки → D
|
||||
|
||||
@property
|
||||
def zone(self) -> str:
|
||||
return {
|
||||
Category.SUITABLE: "B",
|
||||
Category.OVERSIZE: "C",
|
||||
Category.NEED_PACK: "D",
|
||||
}[self]
|
||||
|
||||
@property
|
||||
def ru_label(self) -> str:
|
||||
return {
|
||||
Category.SUITABLE: "Подходит для сортировки",
|
||||
Category.OVERSIZE: "Не подходит для сортировки по габаритам",
|
||||
Category.NEED_PACK: "Не подходит для сортировки без доупаковки",
|
||||
}[self]
|
||||
|
||||
@property
|
||||
def short_label(self) -> str:
|
||||
"""Короткая метка для HUD и веб-статуса."""
|
||||
return {
|
||||
Category.SUITABLE: "ГОТОВ К СОРТИРОВКЕ",
|
||||
Category.OVERSIZE: "НЕГАБАРИТ",
|
||||
Category.NEED_PACK: "ТРЕБУЕТ ДОУПАКОВКИ",
|
||||
}[self]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationResult:
|
||||
category: Category
|
||||
dims_sorted_mm: Tuple[float, float, float]
|
||||
circle_ratio: float
|
||||
passes_size: bool
|
||||
is_circular: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def _sorted_dims(length: float, width: float, height: float) -> Tuple[float, float, float]:
|
||||
a, b, c = sorted([float(length), float(width), float(height)], reverse=True)
|
||||
return a, b, c
|
||||
|
||||
|
||||
def check_size(
|
||||
dims_sorted: Sequence[float],
|
||||
min_mm: Sequence[float],
|
||||
max_mm: Sequence[float],
|
||||
) -> bool:
|
||||
"""
|
||||
ТЗ: габариты строго больше минимума и строго меньше максимума
|
||||
по сопоставленным сторонам после сортировки.
|
||||
"""
|
||||
min_s = sorted([float(x) for x in min_mm], reverse=True)
|
||||
max_s = sorted([float(x) for x in max_mm], reverse=True)
|
||||
d = [float(x) for x in dims_sorted]
|
||||
return all(d[i] > min_s[i] for i in range(3)) and all(d[i] < max_s[i] for i in range(3))
|
||||
|
||||
|
||||
def classify(
|
||||
measurement: ObjectMeasurement,
|
||||
min_mm: Sequence[float] = (10, 10, 10),
|
||||
max_mm: Sequence[float] = (450, 320, 320),
|
||||
circle_ratio_threshold: float = 0.8,
|
||||
) -> ClassificationResult:
|
||||
"""
|
||||
Порядок ТЗ:
|
||||
1) габариты → иначе C (приоритет над кругом)
|
||||
2) если r_in/r_out >= 0.8 в любом сечении → D
|
||||
3) иначе → B
|
||||
"""
|
||||
dims = _sorted_dims(measurement.length_mm, measurement.width_mm, measurement.height_mm)
|
||||
passes = check_size(dims, min_mm, max_mm)
|
||||
ratio = float(measurement.circle_ratio)
|
||||
circular = ratio >= float(circle_ratio_threshold)
|
||||
clipped = bool(getattr(measurement, "clipped_by_frame", False))
|
||||
|
||||
if not passes or clipped:
|
||||
reason = (
|
||||
"объект обрезан краем кадра → габарит неполный, считаем негабаритом"
|
||||
if clipped and passes
|
||||
else "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм"
|
||||
)
|
||||
if clipped and not passes:
|
||||
reason = "габариты вне допуска (в т.ч. обрезан кадром): нужно >10×10×10 и <450×320×320 мм"
|
||||
return ClassificationResult(
|
||||
category=Category.OVERSIZE,
|
||||
dims_sorted_mm=dims,
|
||||
circle_ratio=ratio,
|
||||
passes_size=False,
|
||||
is_circular=circular,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if circular:
|
||||
return ClassificationResult(
|
||||
category=Category.NEED_PACK,
|
||||
dims_sorted_mm=dims,
|
||||
circle_ratio=ratio,
|
||||
passes_size=True,
|
||||
is_circular=True,
|
||||
reason=f"круг в сечении: r_in/r_out={ratio:.3f} >= {circle_ratio_threshold}",
|
||||
)
|
||||
|
||||
return ClassificationResult(
|
||||
category=Category.SUITABLE,
|
||||
dims_sorted_mm=dims,
|
||||
circle_ratio=ratio,
|
||||
passes_size=True,
|
||||
is_circular=False,
|
||||
reason=f"габариты OK, круга нет: r_in/r_out={ratio:.3f} < {circle_ratio_threshold}",
|
||||
)
|
||||
|
||||
|
||||
def classify_from_dims(
|
||||
length_mm: float,
|
||||
width_mm: float,
|
||||
height_mm: float,
|
||||
circle_ratio: float,
|
||||
min_mm: Sequence[float] = (10, 10, 10),
|
||||
max_mm: Sequence[float] = (450, 320, 320),
|
||||
circle_ratio_threshold: float = 0.8,
|
||||
) -> ClassificationResult:
|
||||
fake = ObjectMeasurement(
|
||||
length_mm=length_mm,
|
||||
width_mm=width_mm,
|
||||
height_mm=height_mm,
|
||||
circle_ratio=circle_ratio,
|
||||
area_px=0,
|
||||
centroid_px=(0, 0),
|
||||
contour=None, # type: ignore
|
||||
mask=None, # type: ignore
|
||||
)
|
||||
return classify(fake, min_mm, max_mm, circle_ratio_threshold)
|
||||
151
vision_classifier/collect_log.py
Normal file
151
vision_classifier/collect_log.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Сбор логов классификации с камеры.
|
||||
Кладите предметы по очереди — пишет CSV + печатает сводку.
|
||||
|
||||
.venv/bin/python collect_log.py
|
||||
# Ctrl+C — стоп
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from camera import RealSenseV4L2
|
||||
from measure import measure_object, segment_object
|
||||
from stabilize import DecisionStabilizer
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cfg = yaml.safe_load(Path("config.yaml").read_text(encoding="utf-8"))
|
||||
cam_cfg = cfg["camera"]
|
||||
cls = cfg["classification"]
|
||||
thr = float(cls.get("circle_ratio_threshold", 0.8))
|
||||
|
||||
out_dir = Path("debug_frames")
|
||||
out_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now().strftime("%H%M%S")
|
||||
csv_path = out_dir / f"log_{stamp}.csv"
|
||||
|
||||
cam = RealSenseV4L2(
|
||||
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
|
||||
color_device=cam_cfg.get("color_device", "/dev/video4"),
|
||||
width=int(cam_cfg.get("width", 640)),
|
||||
height=int(cam_cfg.get("height", 480)),
|
||||
fps=int(cam_cfg.get("fps", 30)),
|
||||
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
|
||||
use_color=False,
|
||||
)
|
||||
belt = float(cfg.get("belt_distance_mm") or 600)
|
||||
print(f"[log] belt={belt:.0f} mm thr={thr} → {csv_path}")
|
||||
print("[log] Кладите КРУГ / ПРЯМОУГОЛЬНИК. Ctrl+C — стоп.\n")
|
||||
|
||||
stab = DecisionStabilizer(window=12, confirm_frames=8, lost_frames=12, enter_circle=thr)
|
||||
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
|
||||
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
|
||||
|
||||
f = csv_path.open("w", newline="", encoding="utf-8")
|
||||
w = csv.writer(f)
|
||||
w.writerow(
|
||||
[
|
||||
"t",
|
||||
"present",
|
||||
"L",
|
||||
"W",
|
||||
"H",
|
||||
"top",
|
||||
"sec",
|
||||
"circle",
|
||||
"raw_zone",
|
||||
"lock",
|
||||
"lock_zone",
|
||||
"conf",
|
||||
]
|
||||
)
|
||||
|
||||
last_print = 0.0
|
||||
n = 0
|
||||
try:
|
||||
while True:
|
||||
pair = cam.read()
|
||||
if pair is None:
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
n += 1
|
||||
seg = segment_object(
|
||||
pair.depth_mm,
|
||||
belt_distance_mm=belt,
|
||||
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
|
||||
min_object_height_mm=float(cfg.get("min_object_height_mm", 5)),
|
||||
min_area_px=int(cfg.get("min_object_area_px", 400)),
|
||||
)
|
||||
m = None
|
||||
if seg is not None:
|
||||
mask, contour = seg
|
||||
m = measure_object(
|
||||
pair.depth_mm, mask, contour, belt, fx, fy, cx, cy
|
||||
)
|
||||
|
||||
d = stab.update(
|
||||
m,
|
||||
min_mm=cls.get("min_mm", [10, 10, 10]),
|
||||
max_mm=cls.get("max_mm", [450, 320, 320]),
|
||||
)
|
||||
|
||||
if m is None:
|
||||
raw_zone = "-"
|
||||
row = [time.time(), 0, "", "", "", "", "", "", raw_zone, int(d.locked), "", d.confidence_pct]
|
||||
else:
|
||||
dims = sorted([m.length_mm, m.width_mm, m.height_mm], reverse=True)
|
||||
raw = "C"
|
||||
if all(dims[i] > 10 and dims[i] < [450, 320, 320][i] for i in range(3)):
|
||||
raw = "D" if m.circle_ratio >= thr else "B"
|
||||
lz = d.result.category.zone if (d.locked and d.result) else ""
|
||||
row = [
|
||||
time.time(),
|
||||
1,
|
||||
round(dims[0], 1),
|
||||
round(dims[1], 1),
|
||||
round(dims[2], 1),
|
||||
round(m.top_ratio, 3),
|
||||
round(m.section_ratio, 3),
|
||||
round(m.circle_ratio, 3),
|
||||
raw,
|
||||
int(d.locked),
|
||||
lz,
|
||||
d.confidence_pct,
|
||||
]
|
||||
w.writerow(row)
|
||||
if n % 5 == 0:
|
||||
f.flush()
|
||||
|
||||
now = time.time()
|
||||
if now - last_print > 0.45:
|
||||
last_print = now
|
||||
if m is None:
|
||||
print(f"[{n:05d}] пусто")
|
||||
else:
|
||||
lz = d.result.category.zone if (d.locked and d.result) else "…"
|
||||
print(
|
||||
f"[{n:05d}] raw={row[8]} lock={lz or '—':1s} conf={d.confidence_pct:3d}% | "
|
||||
f"LWH={row[2]:.0f}×{row[3]:.0f}×{row[4]:.0f} | "
|
||||
f"top={m.top_ratio:.3f} sec={m.section_ratio:.3f} circ={m.circle_ratio:.3f}"
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n[log] сохранено {csv_path}")
|
||||
finally:
|
||||
f.close()
|
||||
cam.release()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
110
vision_classifier/config.yaml
Normal file
110
vision_classifier/config.yaml
Normal file
@@ -0,0 +1,110 @@
|
||||
# Конфиг алгоритмической части трека 3 (RealSense D415 → классификация → MQTT)
|
||||
|
||||
camera:
|
||||
depth_device: /dev/video0
|
||||
color_device: /dev/video4
|
||||
width: 640
|
||||
height: 480
|
||||
fps: 30
|
||||
depth_scale_mm: 1.0
|
||||
# Intrinsics депта D415 @ 640x480 (кроп 4:3 из 1280x720, HFOV 65°): fx=fy≈670.
|
||||
# Старое значение 430 завышало габариты в ~1.56 раза!
|
||||
# Уточнить под свою камеру: .venv/bin/python calibrate.py --length 300 --width 200
|
||||
fx: 564.0
|
||||
fy: 564.0
|
||||
cx: 320.0
|
||||
cy: 240.0
|
||||
# Совмещение RGB с depth в демо (сенсоры D415 разнесены ~55 мм).
|
||||
# Подбор: .venv/bin/python align_color.py (нужен объект с чёткими краями)
|
||||
color_dx: -40.0
|
||||
color_dy: -8.0
|
||||
color_scale: 1.030
|
||||
|
||||
# Высота камеры над пустой лентой, мм (оценка по боковым полосам ленты: ~594)
|
||||
belt_distance_mm: 594
|
||||
belt_tolerance_mm: 25
|
||||
min_object_height_mm: 20
|
||||
min_object_area_px: 800
|
||||
# Игнор краёв кадра (ролики/борта/плата справа), доля от размера кадра
|
||||
roi_margin:
|
||||
top: 0.12
|
||||
bottom: 0.02
|
||||
left: 0.05
|
||||
right: 0.12
|
||||
# Сколько объектов одновременно искать в кадре
|
||||
max_objects_in_frame: 3
|
||||
# RGB-картинка в демо (измерения всё равно по depth)
|
||||
use_color: true
|
||||
|
||||
# Фоновая карта глубины: в main.py при старте; в demo — только кнопка «Авто-высота»
|
||||
# false пока тестируем с объектом на ленте (карту снимать только на ПУСТОЙ ленте)
|
||||
use_background_map: false
|
||||
|
||||
# RGB-поиск плоских товаров (телефон). ВЫКЛ по умолчанию — тени/шум давали ложные C.
|
||||
# Включить: true + «Авто-высота» на пустой ленте.
|
||||
detect_flat_rgb: false
|
||||
rgb_diff_threshold: 35
|
||||
|
||||
classification:
|
||||
min_mm: [10, 10, 10]
|
||||
max_mm: [450, 320, 320]
|
||||
circle_ratio_threshold: 0.8
|
||||
# Пограничные случаи: нет консенсуса N кадров (~4 с при 12 fps обработки)
|
||||
# → товар помечается «неуверенно» и уходит в безопасную зону (по умолчанию C).
|
||||
uncertain_after_frames: 45
|
||||
uncertain_fallback_zone: C
|
||||
|
||||
# MQTT — тот же брокер, что слушает ESP32 и SCADA (backend_control)
|
||||
# На этом Orange PI mosquitto уже слушает :1883 → используйте 127.0.0.1
|
||||
# ESP32 в прошивке ждёт 192.168.0.200 — см. INTEGRATION.md
|
||||
mqtt:
|
||||
broker: "127.0.0.1"
|
||||
port: 1883
|
||||
user: "test"
|
||||
password: "1234"
|
||||
client_id: "vision_classifier_opi"
|
||||
topic_result: "vision/feedback/category"
|
||||
topic_dims: "vision/feedback/dimensions"
|
||||
topic_circle: "vision/feedback/circle_ratio"
|
||||
topic_debug: "vision/feedback/debug"
|
||||
enabled: true
|
||||
|
||||
# Шаговик ленты — те же топики, что SCADA (arduino_code не меняем)
|
||||
motor:
|
||||
enabled: true
|
||||
rpm: -200 # скорость ленты; у вас направление — с минусом
|
||||
current_percent: 50
|
||||
microsteps: 16
|
||||
stealthchop: true
|
||||
disable_on_stop: false # при Ctrl+C только rpm=0, драйвер не гасим
|
||||
|
||||
# Серво-селекторы зон B/C/D — подставьте свои каналы/углы
|
||||
routing:
|
||||
enabled: true
|
||||
zones:
|
||||
B: # Подходит для сортировки — пропуск
|
||||
servo: 0
|
||||
idle_angle: 0
|
||||
divert_angle: 0
|
||||
hold_ms: 500
|
||||
C: # Не подходит по габаритам
|
||||
servo: 1
|
||||
idle_angle: 0
|
||||
divert_angle: 90
|
||||
hold_ms: 800
|
||||
D: # Без доупаковки (круг)
|
||||
servo: 2
|
||||
idle_angle: 0
|
||||
divert_angle: 90
|
||||
hold_ms: 800
|
||||
cooldown_ms: 1500
|
||||
|
||||
runtime:
|
||||
show_preview: false
|
||||
save_debug_frames: false
|
||||
debug_dir: "debug_frames"
|
||||
# JSONL-журнал всех LOCK-решений (метрики для отчёта)
|
||||
decisions_log: "logs/decisions.jsonl"
|
||||
preview_every_n: 3
|
||||
process_every_n: 1
|
||||
confirm_frames: 8
|
||||
863
vision_classifier/demo.py
Executable file
863
vision_classifier/demo.py
Executable file
@@ -0,0 +1,863 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Демо-режим хакатона с ползунками в браузере:
|
||||
• высота до ленты (belt_distance_mm)
|
||||
• порог уверенности (сколько кадров подряд одно и то же решение)
|
||||
• мин. высота объекта, порог круга
|
||||
|
||||
Без MQTT / мотора / серво.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
from camera import RealSenseV4L2
|
||||
from classify import Category, ClassificationResult
|
||||
from demo_hud import ContourSmoother, align_color, build_demo_frame
|
||||
from journal import append_decision
|
||||
from measure import (
|
||||
is_plausible_measurement,
|
||||
measure_flat_object,
|
||||
measure_object,
|
||||
merge_overlapping_measurements,
|
||||
segment_objects,
|
||||
segment_rgb_objects,
|
||||
)
|
||||
from stabilize import DecisionStabilizer
|
||||
from tracker import MultiObjectTracker, slot_key
|
||||
|
||||
ZONE_TO_CATEGORY = {
|
||||
"B": Category.SUITABLE,
|
||||
"C": Category.OVERSIZE,
|
||||
"D": Category.NEED_PACK,
|
||||
}
|
||||
|
||||
|
||||
HTML = r"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Трек 3 — демо</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#111114; --card:#1c1c22; --line:#33333c; --txt:#f2f2f4;
|
||||
--muted:#a0a0ab; --acc:#2dd4bf; --b:#22c55e; --c:#ef4444; --d:#f59e0b;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--txt); font-family:system-ui,-apple-system,sans-serif; }
|
||||
.top {
|
||||
position:sticky; top:0; z-index:20;
|
||||
background:var(--card); border-bottom:2px solid var(--acc);
|
||||
padding:12px 16px 14px; box-shadow:0 8px 24px rgba(0,0,0,.45);
|
||||
}
|
||||
.top h1 { margin:0 0 4px; font-size:17px; }
|
||||
.top .sub { margin:0 0 12px; color:var(--muted); font-size:12px; }
|
||||
.sliders {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap:12px 18px;
|
||||
}
|
||||
.sliders label {
|
||||
display:flex; justify-content:space-between; align-items:baseline;
|
||||
font-size:12px; margin-bottom:4px; color:var(--muted);
|
||||
}
|
||||
.sliders label b { color:var(--acc); font-size:14px; font-variant-numeric:tabular-nums; }
|
||||
input[type=range] { width:100%; height:28px; accent-color:var(--acc); cursor:pointer; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; align-items:center; }
|
||||
button {
|
||||
border:0; border-radius:8px; padding:10px 14px; font-weight:700; cursor:pointer;
|
||||
background:var(--acc); color:#042f2e;
|
||||
}
|
||||
button.sec { background:#2a2a32; color:var(--txt); }
|
||||
#st {
|
||||
flex:1; min-width:200px; padding:10px 12px; border-radius:8px;
|
||||
background:#121218; border:1px solid var(--line); font-size:13px; line-height:1.45;
|
||||
}
|
||||
#st .zoneB { color:var(--b); font-weight:800; font-size:18px; }
|
||||
#st .zoneC { color:var(--c); font-weight:800; font-size:18px; }
|
||||
#st .zoneD { color:var(--d); font-weight:800; font-size:18px; }
|
||||
.main {
|
||||
display:flex; gap:12px; padding:12px; max-width:1400px;
|
||||
margin:0 auto; align-items:flex-start;
|
||||
}
|
||||
.stage { flex:1; min-width:0; }
|
||||
.stage img {
|
||||
width:100%; height:auto; display:block;
|
||||
border-radius:10px; border:1px solid var(--line); background:#000;
|
||||
}
|
||||
.side { width:320px; flex-shrink:0; display:flex; flex-direction:column; gap:10px; }
|
||||
.side h3 {
|
||||
margin:0; font-size:12px; text-transform:uppercase; letter-spacing:.08em;
|
||||
color:var(--muted);
|
||||
}
|
||||
.card {
|
||||
background:var(--card); border:1px solid var(--line); border-radius:10px;
|
||||
padding:10px; font-size:13px; line-height:1.5;
|
||||
}
|
||||
.card img {
|
||||
width:100%; height:auto; display:block; border-radius:6px;
|
||||
background:#000; margin-bottom:8px;
|
||||
}
|
||||
.card .hd { font-weight:800; font-size:14px; }
|
||||
.card .mut { color:var(--muted); font-size:12px; }
|
||||
#feed { display:flex; flex-direction:column; gap:8px; overflow-y:auto; max-height:60vh; }
|
||||
.fitem {
|
||||
display:flex; gap:8px; background:var(--card); border:1px solid var(--line);
|
||||
border-radius:10px; padding:8px; font-size:12px; line-height:1.45;
|
||||
}
|
||||
.fitem img {
|
||||
width:86px; height:64px; object-fit:cover; border-radius:6px;
|
||||
background:#000; flex-shrink:0;
|
||||
}
|
||||
.fitem .hd { font-weight:800; font-size:13px; }
|
||||
.fitem .mut { color:var(--muted); }
|
||||
.zB { color:var(--b); } .zC { color:var(--c); } .zD { color:var(--d); }
|
||||
.zU { color:#f97316; }
|
||||
@media (max-width:900px) {
|
||||
.main { flex-direction:column; }
|
||||
.side { width:100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<h1>Трек 3 — демо классификации (B / C / D)</h1>
|
||||
<p class="sub">Ползунки СВЕРХУ (отдельная панель). Картинка только камера. Выход: Ctrl+C в терминале. Обновите страницу Ctrl+F5.</p>
|
||||
<div class="sliders">
|
||||
<div>
|
||||
<label>Высота до ленты, мм <b id="v_belt">600</b></label>
|
||||
<input id="belt" type="range" min="300" max="2000" step="5" value="600"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Порог уверенности, % <b id="v_conf">75</b></label>
|
||||
<input id="conf" type="range" min="10" max="100" step="5" value="75"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Мин. высота объекта, мм <b id="v_hmin">8</b></label>
|
||||
<input id="hmin" type="range" min="2" max="80" step="1" value="8"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Порог «круг» <b id="v_circ">0.80</b></label>
|
||||
<input id="circ" type="range" min="0.50" max="0.95" step="0.01" value="0.80"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Мин. площадь, px <b id="v_area">800</b></label>
|
||||
<input id="area" type="range" min="100" max="5000" step="50" value="800"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div id="st">Загрузка…</div>
|
||||
<button type="button" id="auto">Авто-высота</button>
|
||||
<button type="button" class="sec" id="reset">Сброс</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="stage">
|
||||
<img id="f" src="/frame.jpg?t=0" alt="camera"/>
|
||||
</div>
|
||||
<aside class="side">
|
||||
<h3>Текущий объект</h3>
|
||||
<div id="live"><div class="card mut">объектов нет</div></div>
|
||||
<h3>Лента</h3>
|
||||
<div id="feed"><div class="card mut">пока пусто</div></div>
|
||||
</aside>
|
||||
</div>
|
||||
<script>
|
||||
const img = document.getElementById('f');
|
||||
const ids = ['belt','conf','hmin','circ','area'];
|
||||
const defaults = { belt:600, conf:75, hmin:8, circ:0.80, area:800 };
|
||||
let dragging = false;
|
||||
|
||||
function syncLabels() {
|
||||
v_belt.textContent = belt.value;
|
||||
v_conf.textContent = conf.value;
|
||||
v_hmin.textContent = hmin.value;
|
||||
v_circ.textContent = Number(circ.value).toFixed(2);
|
||||
v_area.textContent = area.value;
|
||||
}
|
||||
|
||||
async function pushParams() {
|
||||
syncLabels();
|
||||
await fetch('/api/params', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
belt_mm: Number(belt.value),
|
||||
confidence_pct: Number(conf.value),
|
||||
min_object_height_mm: Number(hmin.value),
|
||||
circle_threshold: Number(circ.value),
|
||||
min_area_px: Number(area.value),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function pullStatus() {
|
||||
try {
|
||||
const s = await (await fetch('/api/status')).json();
|
||||
let zoneHtml = '<span style="color:#888">объектов нет</span>';
|
||||
if (s.objects && s.objects.length) {
|
||||
zoneHtml = s.objects.map(o => {
|
||||
const dims = o.dims ? (o.dims.map(x => Math.round(x)).join('×') + ' мм') : '';
|
||||
const extra = ' · ' + dims + ' · круг ' + (o.ratio ?? '—');
|
||||
if (o.locked && o.uncertain)
|
||||
return '<span style="color:#f97316;font-weight:800">#' + o.id + ' НЕУВЕРЕННО → ' + o.label + '</span>' + extra;
|
||||
if (o.locked)
|
||||
return '<span class="zone' + o.zone + '">#' + o.id + ' ' + o.label + ' ✓</span>' + extra;
|
||||
return '<span style="color:#38bdf8">#' + o.id + ' анализ… ' + o.conf + '%</span>' + extra;
|
||||
}).join('<br/>');
|
||||
}
|
||||
const stt = s.stats || {};
|
||||
const statsLine = 'Итого: <b style="color:#22c55e">ГОТОВ ' + (stt.B || 0) +
|
||||
'</b> · <b style="color:#ef4444">НЕГАБАРИТ ' + (stt.C || 0) +
|
||||
'</b> · <b style="color:#f59e0b">ДОУПАКОВКА ' + (stt.D || 0) + '</b>' +
|
||||
(stt.uncertain ? ' · неуверенно ' + stt.uncertain : '');
|
||||
st.innerHTML = zoneHtml + '<br/>' + statsLine + '<br/>высота <b>' + s.belt_mm + '</b> мм';
|
||||
renderLive(s.objects || []);
|
||||
if (s.feed_seq !== window.__feedSeq) {
|
||||
window.__feedSeq = s.feed_seq;
|
||||
refreshFeed(s.feed || null);
|
||||
}
|
||||
if (!window.__inited && !dragging) {
|
||||
belt.value = s.belt_mm;
|
||||
conf.value = s.confidence_pct;
|
||||
hmin.value = s.min_object_height_mm;
|
||||
circ.value = s.circle_threshold;
|
||||
area.value = s.min_area_px;
|
||||
syncLabels();
|
||||
window.__inited = true;
|
||||
}
|
||||
} catch (e) { st.textContent = 'Нет связи с demo.py — перезапустите ./demo.sh'; }
|
||||
}
|
||||
|
||||
function zcls(o) {
|
||||
if (o.uncertain) return 'zU';
|
||||
return o.zone ? ('z' + o.zone) : '';
|
||||
}
|
||||
function dimsStr(d) {
|
||||
return d ? d.map(x => Math.round(x)).join('×') + ' мм' : '';
|
||||
}
|
||||
|
||||
function objSig(o) {
|
||||
return o.id + '|' + (o.locked ? 'L' : 'P') + '|' + o.conf + '|' + (o.zone || '') +
|
||||
'|' + (o.label || '') + '|' + (o.dims || []).map(x => Math.round(x)).join(',');
|
||||
}
|
||||
|
||||
function renderLive(objs) {
|
||||
const box = document.getElementById('live');
|
||||
const sig = objs.map(objSig).join(';');
|
||||
if (sig === window.__liveSig) return;
|
||||
window.__liveSig = sig;
|
||||
if (!objs.length) {
|
||||
box.innerHTML = '<div class="card mut">объектов нет</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = objs.map(o => {
|
||||
const img = (o.locked && o.crop) ? '<img src="data:image/jpeg;base64,' + o.crop + '"/>' : '';
|
||||
const head = o.locked
|
||||
? '<span class="hd ' + zcls(o) + '">#' + o.id + ' ' + (o.uncertain ? 'НЕУВЕРЕННО → ' : '') + o.label + (o.zone ? ' · зона ' + o.zone : '') + '</span>'
|
||||
: '<span class="hd" style="color:#38bdf8">#' + o.id + ' анализ… ' + o.conf + '%</span>';
|
||||
const reason = o.reason ? '<div class="mut">' + o.reason + '</div>' : '';
|
||||
return '<div class="card">' + img + head +
|
||||
'<div>' + dimsStr(o.dims) + ' · круг ' + (o.ratio ?? '—') + '</div>' + reason + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function refreshFeed(items) {
|
||||
const box = document.getElementById('feed');
|
||||
if (!window.__feedKeys) window.__feedKeys = new Set();
|
||||
if (!items || !items.length) {
|
||||
if (!window.__feedKeys.size) box.innerHTML = '<div class="card mut">пока пусто</div>';
|
||||
return;
|
||||
}
|
||||
if (window.__feedKeys.size === 0) box.innerHTML = '';
|
||||
for (const it of items.slice().reverse()) {
|
||||
const key = it.slot || ('#' + it.id);
|
||||
if (window.__feedKeys.has(key)) continue;
|
||||
window.__feedKeys.add(key);
|
||||
const img = it.crop ? '<img src="data:image/jpeg;base64,' + it.crop + '"/>' : '<img/>';
|
||||
const el = document.createElement('div');
|
||||
el.className = 'fitem';
|
||||
el.dataset.slot = key;
|
||||
el.innerHTML = img + '<div>' +
|
||||
'<div class="hd ' + zcls(it) + '">#' + it.id + ' ' + (it.uncertain ? 'НЕУВЕР → ' : '') + 'зона ' + it.zone + '</div>' +
|
||||
'<div>' + it.label + '</div>' +
|
||||
'<div class="mut">' + dimsStr(it.dims) + ' · круг ' + it.ratio + ' · ' + it.time + '</div>' +
|
||||
'</div>';
|
||||
box.insertBefore(el, box.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.addEventListener('pointerdown', () => { dragging = true; });
|
||||
el.addEventListener('pointerup', () => { dragging = false; pushParams(); });
|
||||
el.addEventListener('input', () => { syncLabels(); pushParams(); });
|
||||
});
|
||||
|
||||
document.getElementById('auto').onclick = async () => {
|
||||
st.textContent = 'Калибровка… уберите объекты с ленты';
|
||||
const s = await (await fetch('/api/autocalib', {method:'POST'})).json();
|
||||
if (s.ok) {
|
||||
belt.value = Math.round(s.belt_mm);
|
||||
syncLabels();
|
||||
await pushParams();
|
||||
} else st.textContent = 'Ошибка: ' + (s.error || '');
|
||||
};
|
||||
document.getElementById('reset').onclick = () => {
|
||||
belt.value = defaults.belt; conf.value = defaults.conf;
|
||||
hmin.value = defaults.hmin; circ.value = defaults.circ; area.value = defaults.area;
|
||||
syncLabels(); pushParams();
|
||||
};
|
||||
|
||||
setInterval(() => { img.src = '/frame.jpg?t=' + Date.now(); }, 280);
|
||||
setInterval(pullStatus, 350);
|
||||
pullStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Params:
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.belt_mm: float = 800.0
|
||||
self.confidence_pct: int = 75 # порог фиксации
|
||||
self.min_object_height_mm: float = 8.0
|
||||
self.circle_threshold: float = 0.80
|
||||
self.min_area_px: int = 400
|
||||
# runtime status
|
||||
self.confidence_now: int = 0
|
||||
self.zone: Optional[str] = None
|
||||
self.locked: bool = False
|
||||
self.uncertain: bool = False
|
||||
self.circle_ratio: Optional[float] = None
|
||||
self.dims: Optional[tuple] = None
|
||||
self.reason: str = ""
|
||||
self.objects: list = [] # [{id, zone, label, dims, ratio, locked, uncertain, conf, crop}]
|
||||
self.stats: dict = {} # счётчики за сессию
|
||||
self.feed: list = [] # лента LOCK-событий (новые в конце)
|
||||
self.feed_seq: int = 0 # версия ленты — клиент тянет только при изменении
|
||||
self.feed_slots: set = set() # slot_key — один товар = одна карточка в ленте
|
||||
self.crop_cache: dict = {} # track_id → base64, фиксируется при LOCK
|
||||
self.jpeg: bytes = b""
|
||||
self.last_print: str = ""
|
||||
self.cam: Any = None
|
||||
self.request_autocalib: bool = False
|
||||
self.autocalib_result: Optional[Dict[str, Any]] = None
|
||||
self.background: Optional[np.ndarray] = None # карта глубины пустой сцены
|
||||
self.color_background: Optional[np.ndarray] = None # RGB пустой сцены (плоские товары)
|
||||
|
||||
|
||||
STATE = Params()
|
||||
|
||||
|
||||
def make_handler() -> type:
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
return
|
||||
|
||||
def _json(self, code: int, obj: Dict[str, Any]) -> None:
|
||||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path.startswith("/frame.jpg"):
|
||||
with STATE.lock:
|
||||
data = STATE.jpeg
|
||||
if not data:
|
||||
self.send_error(503, "no frame yet")
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/jpeg")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
elif path == "/api/status":
|
||||
with STATE.lock:
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"belt_mm": round(STATE.belt_mm),
|
||||
"confidence_pct": STATE.confidence_pct,
|
||||
"confidence_now": STATE.confidence_now,
|
||||
"min_object_height_mm": STATE.min_object_height_mm,
|
||||
"circle_threshold": STATE.circle_threshold,
|
||||
"min_area_px": STATE.min_area_px,
|
||||
"zone": STATE.zone,
|
||||
"locked": STATE.locked,
|
||||
"uncertain": STATE.uncertain,
|
||||
"circle_ratio": STATE.circle_ratio,
|
||||
"dims": STATE.dims,
|
||||
"reason": STATE.reason,
|
||||
"objects": STATE.objects,
|
||||
"stats": STATE.stats,
|
||||
"feed_seq": STATE.feed_seq,
|
||||
"feed": STATE.feed,
|
||||
},
|
||||
)
|
||||
elif path == "/api/feed":
|
||||
with STATE.lock:
|
||||
self._json(200, {"seq": STATE.feed_seq, "items": STATE.feed})
|
||||
else:
|
||||
body = HTML.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
|
||||
if path == "/api/params":
|
||||
with STATE.lock:
|
||||
if "belt_mm" in data:
|
||||
new_belt = float(np.clip(float(data["belt_mm"]), 200, 3000))
|
||||
# ручная правка высоты → фоновая карта устарела
|
||||
if abs(new_belt - STATE.belt_mm) > 2.0:
|
||||
STATE.background = None
|
||||
STATE.belt_mm = new_belt
|
||||
if "confidence_pct" in data:
|
||||
STATE.confidence_pct = int(np.clip(int(data["confidence_pct"]), 10, 100))
|
||||
if "min_object_height_mm" in data:
|
||||
STATE.min_object_height_mm = float(np.clip(float(data["min_object_height_mm"]), 1, 200))
|
||||
if "circle_threshold" in data:
|
||||
STATE.circle_threshold = float(np.clip(float(data["circle_threshold"]), 0.4, 0.99))
|
||||
if "min_area_px" in data:
|
||||
STATE.min_area_px = int(np.clip(int(data["min_area_px"]), 50, 20000))
|
||||
self._json(200, {"ok": True})
|
||||
elif path == "/api/autocalib":
|
||||
with STATE.lock:
|
||||
STATE.request_autocalib = True
|
||||
STATE.autocalib_result = None
|
||||
# ждём результат от цикла камеры
|
||||
for _ in range(80):
|
||||
time.sleep(0.1)
|
||||
with STATE.lock:
|
||||
if STATE.autocalib_result is not None:
|
||||
self._json(200, STATE.autocalib_result)
|
||||
return
|
||||
self._json(500, {"ok": False, "error": "timeout"})
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def load_config(path: Path) -> Dict[str, Any]:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def frames_needed(confidence_pct: int) -> int:
|
||||
# 10% → 5, 100% → 12 кадров одной зоны после прогрева окна
|
||||
return max(5, int(round(5 + (confidence_pct / 100.0) * 7)))
|
||||
|
||||
|
||||
def crop_b64(img: np.ndarray, contour: np.ndarray, pad: int = 14, max_w: int = 260) -> Optional[str]:
|
||||
"""Кроп объекта по bounding box контура → JPEG base64 для веб-панели."""
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
H, W = img.shape[:2]
|
||||
x0, y0 = max(0, x - pad), max(0, y - pad)
|
||||
x1, y1 = min(W, x + w + pad), min(H, y + h + pad)
|
||||
if x1 - x0 < 4 or y1 - y0 < 4:
|
||||
return None
|
||||
crop = img[y0:y1, x0:x1]
|
||||
if crop.shape[1] > max_w:
|
||||
s = max_w / crop.shape[1]
|
||||
crop = cv2.resize(crop, (max_w, max(1, int(crop.shape[0] * s))))
|
||||
ok, buf = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 78])
|
||||
return base64.b64encode(buf.tobytes()).decode("ascii") if ok else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Демо классификации с ползунками")
|
||||
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = load_config(Path(args.config))
|
||||
cam_cfg = cfg["camera"]
|
||||
cls_cfg = cfg["classification"]
|
||||
min_mm = cls_cfg.get("min_mm", [10, 10, 10])
|
||||
max_mm = cls_cfg.get("max_mm", [450, 320, 320])
|
||||
|
||||
out_dir = Path(cfg.get("runtime", {}).get("debug_dir", "debug_frames"))
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_jpg = out_dir / "demo_live.jpg"
|
||||
|
||||
print("[demo] открываю RealSense D415…")
|
||||
cam = RealSenseV4L2(
|
||||
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
|
||||
color_device=cam_cfg.get("color_device", "/dev/video4"),
|
||||
width=int(cam_cfg.get("width", 640)),
|
||||
height=int(cam_cfg.get("height", 480)),
|
||||
fps=int(cam_cfg.get("fps", 30)),
|
||||
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
|
||||
use_color=bool(cfg.get("use_color", False)),
|
||||
)
|
||||
STATE.cam = cam
|
||||
|
||||
belt0 = float(cfg.get("belt_distance_mm") or 0)
|
||||
if belt0 <= 0:
|
||||
print("[demo] калибровка ленты — уберите объекты…")
|
||||
belt0 = cam.estimate_belt_distance_mm()
|
||||
with STATE.lock:
|
||||
STATE.belt_mm = belt0
|
||||
STATE.circle_threshold = float(cls_cfg.get("circle_ratio_threshold", 0.8))
|
||||
STATE.min_object_height_mm = float(cfg.get("min_object_height_mm", 8))
|
||||
STATE.min_area_px = int(cfg.get("min_object_area_px", 400))
|
||||
STATE.confidence_pct = 75
|
||||
print(f"[demo] belt_distance_mm = {belt0:.0f}")
|
||||
max_objects = int(cfg.get("max_objects_in_frame", 3))
|
||||
detect_flat_rgb = bool(cfg.get("detect_flat_rgb", False))
|
||||
print(f"[demo] max_objects={max_objects}, flat_rgb={'ON' if detect_flat_rgb else 'OFF'}")
|
||||
print("[demo] фоновая карта: кнопка «Авто-высота» на пустой ленте")
|
||||
|
||||
server = ThreadingHTTPServer((args.host, args.port), make_handler())
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
print(f"[demo] браузер → http://127.0.0.1:{args.port}/")
|
||||
print("[demo] ползунки СВЕРХУ страницы (не на картинке)")
|
||||
print("[demo] зона только после LOCK (медиана 12 кадров + голосование)")
|
||||
print("[demo] Ctrl+C — выход\n")
|
||||
|
||||
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
|
||||
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
|
||||
fallback_zone = str(cls_cfg.get("uncertain_fallback_zone", "C")).upper()
|
||||
thr0 = float(cls_cfg.get("circle_ratio_threshold", 0.8))
|
||||
settings = {"confirm": frames_needed(75), "circ": thr0}
|
||||
|
||||
def make_stabilizer() -> DecisionStabilizer:
|
||||
return DecisionStabilizer(
|
||||
window=12,
|
||||
confirm_frames=settings["confirm"],
|
||||
lost_frames=12,
|
||||
enter_circle=settings["circ"],
|
||||
exit_circle=settings["circ"] - 0.08,
|
||||
uncertain_after=int(cls_cfg.get("uncertain_after_frames", 45)),
|
||||
fallback=ZONE_TO_CATEGORY.get(fallback_zone, Category.OVERSIZE),
|
||||
)
|
||||
|
||||
# lost_frames=30 ≈ 1.5–2 с: глянцевые/тёмные предметы (мышка) дают
|
||||
# кратковременные выпадения depth — трек не должен умирать от них
|
||||
tracker = MultiObjectTracker(make_stabilizer, max_dist_px=120, lost_frames=30)
|
||||
contour_smoother = ContourSmoother(alpha=0.3)
|
||||
color_align = (
|
||||
float(cam_cfg.get("color_dx", 0.0)),
|
||||
float(cam_cfg.get("color_dy", 0.0)),
|
||||
float(cam_cfg.get("color_scale", 1.0)),
|
||||
)
|
||||
decisions_log = Path(cfg.get("runtime", {}).get("decisions_log", "logs/decisions.jsonl"))
|
||||
frame_i = 0
|
||||
last_conf_setting = 75
|
||||
last_circ = thr0
|
||||
|
||||
try:
|
||||
while True:
|
||||
# автокалибровка по запросу из UI
|
||||
with STATE.lock:
|
||||
need_auto = STATE.request_autocalib
|
||||
if need_auto:
|
||||
STATE.request_autocalib = False
|
||||
if need_auto:
|
||||
try:
|
||||
print("[demo] автокалибровка: снимаю фоновую карту (сцена должна быть пустой)…")
|
||||
bg = cam.capture_background(samples=15)
|
||||
color_bg = cam.capture_background_rgb(samples=10)
|
||||
h, w = bg.shape
|
||||
center = bg[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4].astype(np.float32)
|
||||
center = center[(center > 200) & (center < 4000)]
|
||||
new_belt = float(np.median(center)) if center.size > 100 else cam.estimate_belt_distance_mm(samples=10)
|
||||
with STATE.lock:
|
||||
STATE.belt_mm = new_belt
|
||||
STATE.background = bg
|
||||
STATE.color_background = color_bg
|
||||
STATE.autocalib_result = {"ok": True, "belt_mm": new_belt}
|
||||
tracker.reset()
|
||||
rgb_tag = "RGB-фон есть" if color_bg is not None else "RGB-фон недоступен"
|
||||
print(f"[demo] высота = {new_belt:.0f} mm, фоновая карта активна, {rgb_tag}")
|
||||
except Exception as exc:
|
||||
with STATE.lock:
|
||||
STATE.autocalib_result = {"ok": False, "error": str(exc)}
|
||||
|
||||
pair = cam.read()
|
||||
if pair is None:
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
frame_i += 1
|
||||
|
||||
with STATE.lock:
|
||||
belt_mm = STATE.belt_mm
|
||||
conf_pct = STATE.confidence_pct
|
||||
hmin = STATE.min_object_height_mm
|
||||
circ_thr = STATE.circle_threshold
|
||||
min_area = STATE.min_area_px
|
||||
background = STATE.background
|
||||
color_background = STATE.color_background
|
||||
|
||||
if conf_pct != last_conf_setting:
|
||||
settings["confirm"] = frames_needed(conf_pct)
|
||||
tracker.reset()
|
||||
last_conf_setting = conf_pct
|
||||
if abs(circ_thr - last_circ) > 1e-6:
|
||||
settings["circ"] = float(circ_thr)
|
||||
tracker.reset()
|
||||
last_circ = circ_thr
|
||||
|
||||
# несколько объектов в кадре → трекер с ID
|
||||
measurements = []
|
||||
depth_union = None
|
||||
seg_n = 0
|
||||
for mask, contour in segment_objects(
|
||||
pair.depth_mm,
|
||||
belt_distance_mm=belt_mm,
|
||||
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
|
||||
min_object_height_mm=hmin,
|
||||
min_area_px=min_area,
|
||||
background_mm=background,
|
||||
max_objects=max_objects,
|
||||
):
|
||||
seg_n += 1
|
||||
depth_union = mask if depth_union is None else cv2.bitwise_or(depth_union, mask)
|
||||
m = measure_object(
|
||||
pair.depth_mm,
|
||||
mask,
|
||||
contour,
|
||||
belt_distance_mm=belt_mm,
|
||||
fx=fx,
|
||||
fy=fy,
|
||||
cx=cx,
|
||||
cy=cy,
|
||||
background_mm=background,
|
||||
min_object_height_mm=hmin,
|
||||
)
|
||||
if m is not None and is_plausible_measurement(m):
|
||||
measurements.append(m)
|
||||
|
||||
# плоские товары — только если detect_flat_rgb: true (иначе тени → ложные C)
|
||||
if (
|
||||
detect_flat_rgb
|
||||
and color_background is not None
|
||||
and not pair.color_is_depth_preview
|
||||
):
|
||||
for mask, contour in segment_rgb_objects(
|
||||
pair.color_bgr,
|
||||
color_background,
|
||||
min_area_px=min_area,
|
||||
diff_threshold=int(cfg.get("rgb_diff_threshold", 35)),
|
||||
max_objects=max(0, max_objects - len(measurements)),
|
||||
exclude_mask=depth_union,
|
||||
):
|
||||
m = measure_flat_object(
|
||||
pair.depth_mm,
|
||||
mask,
|
||||
contour,
|
||||
belt_distance_mm=belt_mm,
|
||||
fx=fx,
|
||||
fy=fy,
|
||||
cx=cx,
|
||||
cy=cy,
|
||||
background_mm=background,
|
||||
color_bgr=pair.color_bgr,
|
||||
color_bg_bgr=color_background,
|
||||
)
|
||||
if m is not None and is_plausible_measurement(m):
|
||||
measurements.append(m)
|
||||
|
||||
measurements = merge_overlapping_measurements(measurements, overlap_thr=0.5)
|
||||
tracks, events = tracker.update(measurements, min_mm=min_mm, max_mm=max_mm)
|
||||
|
||||
rgb_available = not pair.color_is_depth_preview
|
||||
if rgb_available and float(np.std(pair.color_bgr)) > 4.0:
|
||||
base_img = pair.color_bgr
|
||||
if base_img.shape[:2] != pair.depth_mm.shape[:2]:
|
||||
base_img = cv2.resize(base_img, (pair.depth_mm.shape[1], pair.depth_mm.shape[0]))
|
||||
base_img = align_color(base_img, *color_align)
|
||||
else:
|
||||
# без живого RGB — colorize(depth), иначе веб был бы чёрным
|
||||
from camera import depth_colormap
|
||||
base_img = depth_colormap(pair.depth_mm)
|
||||
rgb_available = False
|
||||
|
||||
crops: Dict[int, Optional[str]] = {}
|
||||
for tr in tracks:
|
||||
if tr.measurement is not None:
|
||||
crops[tr.track_id] = crop_b64(base_img, tr.measurement.contour)
|
||||
|
||||
crop_updates: Dict[int, str] = {}
|
||||
feed_add = []
|
||||
for ev in events:
|
||||
r = ev.decision.result
|
||||
tag = "UNCERTAIN→" if ev.decision.uncertain else "LOCK "
|
||||
print(
|
||||
f"[demo] #{ev.track_id} {tag}{r.category.zone} | {r.category.short_label} | "
|
||||
f"LWH={tuple(round(x, 1) for x in r.dims_sorted_mm)} | circle={r.circle_ratio:.3f}"
|
||||
)
|
||||
append_decision(
|
||||
decisions_log, r,
|
||||
uncertain=ev.decision.uncertain, source="demo", track_id=ev.track_id,
|
||||
)
|
||||
L, W, H = r.dims_sorted_mm
|
||||
tr_ev = next((t for t in tracks if t.track_id == ev.track_id), None)
|
||||
cx, cy = (tr_ev.centroid if tr_ev else (0, 0))
|
||||
sk = slot_key(cx, cy, L, W, H, r.category.zone)
|
||||
crop = crops.get(ev.track_id)
|
||||
if crop:
|
||||
crop_updates[ev.track_id] = crop
|
||||
feed_add.append({
|
||||
"slot": sk,
|
||||
"id": ev.track_id,
|
||||
"time": time.strftime("%H:%M:%S"),
|
||||
"zone": r.category.zone,
|
||||
"label": r.category.short_label,
|
||||
"dims": [round(x, 1) for x in r.dims_sorted_mm],
|
||||
"ratio": round(r.circle_ratio, 3),
|
||||
"uncertain": bool(ev.decision.uncertain),
|
||||
"crop": crop,
|
||||
})
|
||||
|
||||
with STATE.lock:
|
||||
STATE.crop_cache.update(crop_updates)
|
||||
crop_cache = dict(STATE.crop_cache)
|
||||
|
||||
# статус для веба: список объектов + «главный» (первый залоченный)
|
||||
objects_json = []
|
||||
primary = None
|
||||
for tr in tracks:
|
||||
d = tr.decision
|
||||
m = tr.measurement
|
||||
if d is None or m is None:
|
||||
continue
|
||||
is_locked = bool(d.locked and d.result is not None)
|
||||
if is_locked:
|
||||
obj = {
|
||||
"id": tr.track_id,
|
||||
"locked": True,
|
||||
"uncertain": bool(d.uncertain),
|
||||
"conf": 100,
|
||||
"zone": d.result.category.zone,
|
||||
"label": d.result.category.short_label,
|
||||
"dims": [round(x, 1) for x in d.result.dims_sorted_mm],
|
||||
"ratio": round(d.result.circle_ratio, 3),
|
||||
"reason": d.result.reason,
|
||||
"crop": crop_cache.get(tr.track_id),
|
||||
}
|
||||
else:
|
||||
obj = {
|
||||
"id": tr.track_id,
|
||||
"locked": False,
|
||||
"uncertain": False,
|
||||
"conf": d.confidence_pct,
|
||||
"zone": None,
|
||||
"label": "анализ…",
|
||||
"dims": [round(m.length_mm, 1), round(m.width_mm, 1), round(m.height_mm, 1)],
|
||||
"ratio": round(m.circle_ratio, 3),
|
||||
"reason": "",
|
||||
"crop": None,
|
||||
}
|
||||
objects_json.append(obj)
|
||||
if primary is None or (obj["locked"] and not primary["locked"]):
|
||||
primary = obj
|
||||
|
||||
with STATE.lock:
|
||||
STATE.objects = objects_json
|
||||
STATE.stats = dict(tracker.stats)
|
||||
if feed_add:
|
||||
fresh = [it for it in feed_add if it["slot"] not in STATE.feed_slots]
|
||||
for it in fresh:
|
||||
STATE.feed_slots.add(it["slot"])
|
||||
if fresh:
|
||||
STATE.feed.extend(fresh)
|
||||
STATE.feed = STATE.feed[-20:]
|
||||
STATE.feed_seq += 1
|
||||
if primary is not None:
|
||||
STATE.confidence_now = primary["conf"]
|
||||
STATE.locked = primary["locked"]
|
||||
STATE.uncertain = primary["uncertain"]
|
||||
STATE.zone = primary["zone"]
|
||||
STATE.circle_ratio = primary["ratio"]
|
||||
STATE.dims = tuple(primary["dims"])
|
||||
STATE.reason = primary["reason"] or (
|
||||
f"накопление {primary['conf']}% → ждём LOCK" if not primary["locked"] else ""
|
||||
)
|
||||
else:
|
||||
STATE.confidence_now = 0
|
||||
STATE.locked = False
|
||||
STATE.uncertain = False
|
||||
STATE.zone = None
|
||||
STATE.circle_ratio = None
|
||||
STATE.dims = None
|
||||
STATE.reason = ""
|
||||
|
||||
hud = build_demo_frame(
|
||||
base_img,
|
||||
pair.depth_mm,
|
||||
tracks,
|
||||
belt_mm,
|
||||
stats=tracker.stats,
|
||||
confidence_pct=conf_pct,
|
||||
rgb_available=rgb_available,
|
||||
background_active=background is not None,
|
||||
color_align=(0.0, 0.0, 1.0),
|
||||
contour_smoother=contour_smoother,
|
||||
)
|
||||
ok, buf = cv2.imencode(".jpg", hud, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if ok:
|
||||
jpeg = buf.tobytes()
|
||||
with STATE.lock:
|
||||
STATE.jpeg = jpeg
|
||||
if frame_i % 3 == 0:
|
||||
out_jpg.write_bytes(jpeg)
|
||||
|
||||
if seg_n > len(measurements) and STATE.last_print != "seg_drop":
|
||||
print(f"[demo] depth: контуров {seg_n}, измерено {len(measurements)} "
|
||||
f"(часть отфильтрована: низкая высота < {hmin:.0f} мм или шум)")
|
||||
STATE.last_print = "seg_drop"
|
||||
elif not tracks and STATE.last_print != "empty":
|
||||
print("[demo] объектов нет")
|
||||
STATE.last_print = "empty"
|
||||
elif tracks and STATE.last_print in ("seg_drop", "empty"):
|
||||
STATE.last_print = ""
|
||||
|
||||
time.sleep(0.03)
|
||||
except KeyboardInterrupt:
|
||||
print("\n[demo] stop")
|
||||
finally:
|
||||
server.shutdown()
|
||||
cam.release()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
raise SystemExit(main())
|
||||
14
vision_classifier/demo.sh
Executable file
14
vision_classifier/demo.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
if [[ ! -d .venv ]]; then
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -U pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
echo "Демо классификации (без моторов/серво)"
|
||||
echo "Браузер: http://127.0.0.1:8080/"
|
||||
exec .venv/bin/python demo.py "$@"
|
||||
302
vision_classifier/demo_hud.py
Normal file
302
vision_classifier/demo_hud.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""HUD демо: RGB-подложка + depth, несколько объектов с ID, кириллица через PIL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ZONE_COLOR = { # BGR
|
||||
"B": (40, 180, 40),
|
||||
"C": (40, 40, 220),
|
||||
"D": (0, 165, 255),
|
||||
}
|
||||
UNCERTAIN_COLOR = (0, 130, 250) # оранжевый
|
||||
PENDING_COLOR = (0, 255, 255) # жёлтый — идёт накопление
|
||||
|
||||
_FONT_CANDIDATES = [
|
||||
"/usr/share/fonts/noto/NotoSans-Bold.ttf",
|
||||
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
]
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _font(size: int) -> ImageFont.FreeTypeFont:
|
||||
for path in _FONT_CANDIDATES:
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _draw_texts(
|
||||
img_bgr: np.ndarray,
|
||||
texts: List[Tuple[int, int, str, Tuple[int, int, int], int]],
|
||||
) -> np.ndarray:
|
||||
"""texts: (x, y, строка, цвет BGR, размер). Кириллица через PIL."""
|
||||
if not texts:
|
||||
return img_bgr
|
||||
pil = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
|
||||
draw = ImageDraw.Draw(pil)
|
||||
for x, y, s, bgr, size in texts:
|
||||
rgb = (bgr[2], bgr[1], bgr[0])
|
||||
draw.text((x, y), s, font=_font(size), fill=rgb, stroke_width=2, stroke_fill=(0, 0, 0))
|
||||
return cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
|
||||
|
||||
|
||||
class DepthSmoother:
|
||||
"""Временное сглаживание depth только для отображения (не для измерений)."""
|
||||
|
||||
def __init__(self, alpha: float = 0.25) -> None:
|
||||
self.alpha = float(alpha)
|
||||
self._acc: Optional[np.ndarray] = None
|
||||
|
||||
def update(self, depth_mm: np.ndarray) -> np.ndarray:
|
||||
d = depth_mm.astype(np.float32)
|
||||
if self._acc is None or self._acc.shape != d.shape:
|
||||
self._acc = d.copy()
|
||||
valid = d > 0
|
||||
self._acc[valid] = (1.0 - self.alpha) * self._acc[valid] + self.alpha * d[valid]
|
||||
out = self._acc.astype(np.uint16)
|
||||
out[~valid & (self._acc <= 0)] = 0
|
||||
return out
|
||||
|
||||
|
||||
class ContourSmoother:
|
||||
"""Стабильная окантовка: EMA маски по каждому треку + аппроксимация контура,
|
||||
плюс «примагничивание» контура к краям объекта на RGB (снимает остаточный
|
||||
параллакс depth↔color и распухание depth-маски).
|
||||
|
||||
Только для отрисовки — измерения идут по сырому контуру.
|
||||
"""
|
||||
|
||||
def __init__(self, alpha: float = 0.3, snap_alpha: float = 0.35) -> None:
|
||||
self.alpha = float(alpha)
|
||||
self.snap_alpha = float(snap_alpha)
|
||||
self._acc: Dict[int, np.ndarray] = {}
|
||||
self._snap: Dict[int, Tuple[float, float, float]] = {} # tid -> (dx, dy, shrink)
|
||||
|
||||
def smooth(
|
||||
self, track_id: int, mask: np.ndarray, edge_img: Optional[np.ndarray] = None
|
||||
) -> Optional[np.ndarray]:
|
||||
m = mask.astype(np.float32) / 255.0
|
||||
acc = self._acc.get(track_id)
|
||||
if acc is None or acc.shape != m.shape:
|
||||
acc = m.copy()
|
||||
else:
|
||||
acc = (1.0 - self.alpha) * acc + self.alpha * m
|
||||
self._acc[track_id] = acc
|
||||
|
||||
soft = cv2.GaussianBlur((acc * 255.0).astype(np.uint8), (11, 11), 0)
|
||||
_, binm = cv2.threshold(soft, 127, 255, cv2.THRESH_BINARY)
|
||||
contours, _ = cv2.findContours(binm, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
return None
|
||||
contour = max(contours, key=cv2.contourArea)
|
||||
|
||||
pts = contour.reshape(-1, 2).astype(np.float32)
|
||||
if edge_img is not None and pts.shape[0] >= 8:
|
||||
pts = self._snap_to_edges(track_id, pts, edge_img)
|
||||
|
||||
contour = pts.reshape(-1, 1, 2).astype(np.int32)
|
||||
eps = 0.008 * cv2.arcLength(contour, True)
|
||||
return cv2.approxPolyDP(contour, eps, True)
|
||||
|
||||
def _snap_to_edges(
|
||||
self, track_id: int, pts: np.ndarray, edge: np.ndarray
|
||||
) -> np.ndarray:
|
||||
"""Локальный поиск сдвига (±14 px) и поджатия контура, при которых под
|
||||
контуром максимум RGB-краёв. Найденная поправка сглаживается по времени."""
|
||||
H, W = edge.shape[:2]
|
||||
xs, ys = pts[:, 0], pts[:, 1]
|
||||
|
||||
def score(dx: float, dy: float, f: float, c: np.ndarray) -> float:
|
||||
x = np.clip((c[0] + f * (xs - c[0]) + dx).astype(np.int32), 0, W - 1)
|
||||
y = np.clip((c[1] + f * (ys - c[1]) + dy).astype(np.int32), 0, H - 1)
|
||||
return float(edge[y, x].mean()) - 0.6 * float(np.hypot(dx, dy))
|
||||
|
||||
c = pts.mean(axis=0)
|
||||
best_dx, best_dy, best_s = 0.0, 0.0, score(0, 0, 1.0, c)
|
||||
for dy in range(-14, 15, 2):
|
||||
for dx in range(-14, 15, 2):
|
||||
s = score(dx, dy, 1.0, c)
|
||||
if s > best_s:
|
||||
best_s, best_dx, best_dy = s, float(dx), float(dy)
|
||||
best_f = 1.0
|
||||
# только сдвиг и лёгкое РАСШИРЕНИЕ — поджатие (f<1) отрезало часть объекта
|
||||
for f in (1.0, 1.04, 1.08):
|
||||
s = score(best_dx, best_dy, f, c)
|
||||
if s > best_s:
|
||||
best_s, best_f = s, f
|
||||
|
||||
prev = self._snap.get(track_id, (0.0, 0.0, 1.0))
|
||||
a = self.snap_alpha
|
||||
sm = (
|
||||
(1 - a) * prev[0] + a * best_dx,
|
||||
(1 - a) * prev[1] + a * best_dy,
|
||||
max(1.0, (1 - a) * prev[2] + a * best_f),
|
||||
)
|
||||
self._snap[track_id] = sm
|
||||
out = pts.copy()
|
||||
out[:, 0] = c[0] + sm[2] * (xs - c[0]) + sm[0]
|
||||
out[:, 1] = c[1] + sm[2] * (ys - c[1]) + sm[1]
|
||||
return out
|
||||
|
||||
def drop_missing(self, alive_ids: set) -> None:
|
||||
for tid in list(self._acc.keys()):
|
||||
if tid not in alive_ids:
|
||||
del self._acc[tid]
|
||||
self._snap.pop(tid, None)
|
||||
|
||||
|
||||
def align_color(color_bgr: np.ndarray, dx: float, dy: float, scale: float) -> np.ndarray:
|
||||
"""Совмещение RGB с depth: сдвиг+масштаб (у D415 сенсоры разнесены)."""
|
||||
if abs(dx) < 0.5 and abs(dy) < 0.5 and abs(scale - 1.0) < 1e-3:
|
||||
return color_bgr
|
||||
h, w = color_bgr.shape[:2]
|
||||
M = np.float32([
|
||||
[scale, 0, dx + (1.0 - scale) * w / 2.0],
|
||||
[0, scale, dy + (1.0 - scale) * h / 2.0],
|
||||
])
|
||||
return cv2.warpAffine(color_bgr, M, (w, h), flags=cv2.INTER_LINEAR)
|
||||
|
||||
|
||||
def build_demo_frame(
|
||||
color_bgr: np.ndarray,
|
||||
depth_mm: np.ndarray,
|
||||
tracks: list, # List[tracker.Track]
|
||||
belt_mm: float,
|
||||
stats: Optional[Dict[str, int]] = None,
|
||||
confidence_pct: int = 75,
|
||||
rgb_available: bool = False,
|
||||
background_active: bool = False,
|
||||
color_align: Tuple[float, float, float] = (0.0, 0.0, 1.0),
|
||||
contour_smoother: Optional[ContourSmoother] = None,
|
||||
) -> np.ndarray:
|
||||
from camera import depth_colormap
|
||||
|
||||
# RGB для веба; если цвет недоступен — colorize(depth), НЕ чёрный экран
|
||||
if rgb_available and color_bgr is not None and float(np.std(color_bgr)) > 4.0:
|
||||
base = color_bgr
|
||||
if base.shape[:2] != depth_mm.shape[:2]:
|
||||
base = cv2.resize(base, (depth_mm.shape[1], depth_mm.shape[0]))
|
||||
base = align_color(base, color_align[0], color_align[1], color_align[2])
|
||||
view = base.copy()
|
||||
rgb_ok = True
|
||||
else:
|
||||
view = depth_colormap(depth_mm)
|
||||
view = cv2.medianBlur(view, 3)
|
||||
base = view
|
||||
rgb_ok = False
|
||||
h, w = view.shape[:2]
|
||||
|
||||
# мягкое поле RGB-краёв для «примагничивания» контуров
|
||||
edge_field: Optional[np.ndarray] = None
|
||||
if rgb_ok and contour_smoother is not None and tracks:
|
||||
gray = cv2.cvtColor(base, cv2.COLOR_BGR2GRAY)
|
||||
gray = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)).apply(gray)
|
||||
edge_field = cv2.Canny(cv2.GaussianBlur(gray, (5, 5), 0), 40, 120)
|
||||
edge_field = cv2.GaussianBlur(edge_field, (13, 13), 0)
|
||||
|
||||
texts: List[Tuple[int, int, str, Tuple[int, int, int], int]] = []
|
||||
max_conf_pending = 0
|
||||
all_locked = bool(tracks)
|
||||
alive_ids = set()
|
||||
|
||||
for tr in tracks:
|
||||
m = tr.measurement
|
||||
d = tr.decision
|
||||
if m is None or d is None:
|
||||
continue
|
||||
alive_ids.add(tr.track_id)
|
||||
locked = d.locked and d.result is not None
|
||||
if locked:
|
||||
color = UNCERTAIN_COLOR if d.uncertain else ZONE_COLOR.get(d.result.category.zone, PENDING_COLOR)
|
||||
else:
|
||||
color = PENDING_COLOR
|
||||
all_locked = False
|
||||
max_conf_pending = max(max_conf_pending, d.confidence_pct)
|
||||
|
||||
# bbox / класс — по сырому контуру измерения; сглаживание только для окантовки «анализ»
|
||||
raw_contour = m.contour
|
||||
draw_contour = raw_contour
|
||||
if not locked and contour_smoother is not None:
|
||||
sm = contour_smoother.smooth(tr.track_id, m.mask, edge_img=edge_field)
|
||||
if sm is not None:
|
||||
draw_contour = sm
|
||||
if locked:
|
||||
bx, by, bw, bh = cv2.boundingRect(raw_contour)
|
||||
# небольшой запас, чтобы рамка не обрезала края
|
||||
pad = 4
|
||||
bx, by = max(0, bx - pad), max(0, by - pad)
|
||||
bw = min(w - bx, bw + 2 * pad)
|
||||
bh = min(h - by, bh + 2 * pad)
|
||||
cv2.rectangle(view, (bx, by), (bx + bw, by + bh), color, 2, lineType=cv2.LINE_AA)
|
||||
cl = max(8, min(bw, bh) // 5)
|
||||
for px, py, sx, sy in ((bx, by, 1, 1), (bx + bw, by, -1, 1),
|
||||
(bx, by + bh, 1, -1), (bx + bw, by + bh, -1, -1)):
|
||||
cv2.line(view, (px, py), (px + sx * cl, py), color, 4, lineType=cv2.LINE_AA)
|
||||
cv2.line(view, (px, py), (px, py + sy * cl), color, 4, lineType=cv2.LINE_AA)
|
||||
contour = raw_contour
|
||||
else:
|
||||
cv2.drawContours(view, [draw_contour], -1, color, 2, lineType=cv2.LINE_AA)
|
||||
contour = draw_contour
|
||||
ccx, ccy = contour.reshape(-1, 2).mean(axis=0)
|
||||
cv2.circle(view, (int(ccx), int(ccy)), 4, (0, 0, 255), -1, lineType=cv2.LINE_AA)
|
||||
|
||||
x0, y0, _, _ = cv2.boundingRect(contour)
|
||||
tx = int(np.clip(x0, 4, w - 220))
|
||||
ty = int(np.clip(y0 - 46, 4, h - 46))
|
||||
if locked:
|
||||
label = d.result.category.short_label
|
||||
if d.uncertain:
|
||||
label = "НЕУВЕРЕННО → " + label
|
||||
texts.append((tx, ty, f"#{tr.track_id} {label}", color, 20))
|
||||
else:
|
||||
texts.append((tx, ty, f"#{tr.track_id} анализ… {d.confidence_pct}%", color, 20))
|
||||
dims = d.result.dims_sorted_mm if (locked and d.result) else (m.length_mm, m.width_mm, m.height_mm)
|
||||
ratio = d.result.circle_ratio if (locked and d.result) else m.circle_ratio
|
||||
src = " · RGB" if getattr(m, "source", "depth") == "rgb" else ""
|
||||
texts.append(
|
||||
(tx, ty + 24, f"{dims[0]:.0f}×{dims[1]:.0f}×{dims[2]:.0f} мм · круг {ratio:.2f}{src}", (235, 235, 235), 15)
|
||||
)
|
||||
|
||||
if contour_smoother is not None:
|
||||
contour_smoother.drop_missing(alive_ids)
|
||||
|
||||
view = _draw_texts(view, texts)
|
||||
|
||||
# надписи — в отдельной полосе НАД кадром, чтобы не закрывать камеру
|
||||
top_bar = np.full((36, w, 3), 18, np.uint8)
|
||||
st = stats or {}
|
||||
stats_line = (
|
||||
f"ГОТОВ {st.get('B', 0)} · НЕГАБАРИТ {st.get('C', 0)} · ДОУПАК {st.get('D', 0)}"
|
||||
+ (f" · неувер. {st['uncertain']}" if st.get("uncertain") else "")
|
||||
)
|
||||
bg_tag = " · фон:карта" if background_active else ""
|
||||
left = "объектов нет" if not tracks else f"объектов: {len(alive_ids)}"
|
||||
top_texts = [
|
||||
(10, 6, left, (150, 150, 255) if not tracks else (200, 230, 200), 17),
|
||||
(max(200, w - 440), 8, f"{stats_line} | h={belt_mm:.0f}мм{bg_tag}", (200, 230, 200), 14),
|
||||
]
|
||||
top_bar = _draw_texts(top_bar, top_texts)
|
||||
view = np.vstack([top_bar, view])
|
||||
h = view.shape[0]
|
||||
|
||||
# прогресс уверенности внизу
|
||||
bar_y = h - 8
|
||||
cv2.rectangle(view, (0, bar_y), (w, h), (40, 40, 40), -1)
|
||||
conf_show = 100 if (all_locked and tracks) else max_conf_pending
|
||||
fill = int(w * min(1.0, conf_show / 100.0))
|
||||
col = (40, 200, 40) if (all_locked and tracks) else (0, 200, 255)
|
||||
cv2.rectangle(view, (0, bar_y), (fill, h), col, -1)
|
||||
thr = int(w * confidence_pct / 100.0)
|
||||
cv2.line(view, (thr, bar_y), (thr, h), (255, 255, 255), 1)
|
||||
|
||||
return view
|
||||
23
vision_classifier/docker-compose.yml
Normal file
23
vision_classifier/docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
vision:
|
||||
build: .
|
||||
container_name: realsense_vision
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
privileged: true
|
||||
devices:
|
||||
- /dev/video0:/dev/video0
|
||||
- /dev/video1:/dev/video1
|
||||
- /dev/video2:/dev/video2
|
||||
- /dev/video3:/dev/video3
|
||||
- /dev/video4:/dev/video4
|
||||
- /dev/video5:/dev/video5
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
- ./debug_frames:/app/debug_frames
|
||||
group_add:
|
||||
- video
|
||||
environment:
|
||||
- QT_X11_NO_MITSHM=1
|
||||
# Без GUI в контейнере по умолчанию; превью — через native run
|
||||
command: ["python", "main.py", "-c", "config.yaml"]
|
||||
47
vision_classifier/journal.py
Normal file
47
vision_classifier/journal.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""JSONL-журнал решений классификатора — метрики для отчёта и защиты.
|
||||
|
||||
Каждая строка — одно зафиксированное решение (LOCK):
|
||||
время, зона, габариты, circle_ratio, флаг «неуверенно», причина.
|
||||
|
||||
Анализ (корректность, доля неуверенных, распределение зон):
|
||||
.venv/bin/python -c "
|
||||
import json;
|
||||
rows=[json.loads(l) for l in open('logs/decisions.jsonl')];
|
||||
from collections import Counter;
|
||||
print(Counter(r['zone'] for r in rows));
|
||||
print('uncertain:', sum(r['uncertain'] for r in rows), '/', len(rows))"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from classify import ClassificationResult
|
||||
|
||||
|
||||
def append_decision(
|
||||
path: str | Path,
|
||||
result: ClassificationResult,
|
||||
uncertain: bool = False,
|
||||
source: str = "main",
|
||||
track_id: int | None = None,
|
||||
) -> None:
|
||||
entry = {
|
||||
"track_id": track_id,
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"ts_ms": int(time.time() * 1000),
|
||||
"zone": result.category.zone,
|
||||
"category": result.category.value,
|
||||
"label_ru": result.category.ru_label,
|
||||
"dims_mm": [round(float(x), 1) for x in result.dims_sorted_mm],
|
||||
"circle_ratio": round(float(result.circle_ratio), 4),
|
||||
"uncertain": bool(uncertain),
|
||||
"reason": result.reason,
|
||||
"source": source,
|
||||
}
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(p, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
742
vision_classifier/logs/decisions.jsonl
Normal file
742
vision_classifier/logs/decisions.jsonl
Normal file
@@ -0,0 +1,742 @@
|
||||
{"ts": "2026-07-28T17:10:28", "ts_ms": 1785247828677, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [253.7, 116.5, 114.1], "circle_ratio": 0.5161, "uncertain": false, "reason": "не круг: med=0.516 sec=0.435 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829097, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829149, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829363, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829417, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829740, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829964, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830018, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830070, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830126, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830287, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830341, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830397, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830553, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830816, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830867, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831023, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831234, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831287, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831443, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831495, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831600, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831706, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831758, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831810, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832279, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832384, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832438, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832544, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832989, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833045, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833100, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833156, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833324, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833379, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833434, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833490, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833599, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833654, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833821, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833877, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833933, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833987, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:10:34", "ts_ms": 1785247834243, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:27", "ts_ms": 1785247947835, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:27", "ts_ms": 1785247947951, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948058, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948116, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948171, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948229, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948283, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948335, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948394, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948451, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948611, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948874, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948926, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948977, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949029, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949188, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949295, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949348, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949451, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949502, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949550, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949705, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949757, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949867, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949919, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949971, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950074, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950135, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950242, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950295, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950449, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950607, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950658, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950713, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950765, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950884, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950992, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951044, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951097, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951261, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951376, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951628, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951680, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951731, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951949, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952152, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952357, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952411, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952616, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952669, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952876, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952932, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953193, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953248, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953300, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953352, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953404, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953457, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953562, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953615, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953663, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953773, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953828, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953885, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:34", "ts_ms": 1785247954389, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [196.1, 157.5, 95.9], "circle_ratio": 0.4872, "uncertain": false, "reason": "не круг: med=0.487 sec=0.398 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:34", "ts_ms": 1785247954549, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [196.1, 157.5, 95.9], "circle_ratio": 0.4872, "uncertain": false, "reason": "не круг: med=0.487 sec=0.398 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:34", "ts_ms": 1785247954969, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955608, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955711, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955764, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955922, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955975, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956029, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956187, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956449, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956657, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956761, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956874, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956927, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957084, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957407, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957619, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957834, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957888, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957944, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958158, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958266, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958477, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958690, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958744, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958906, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958959, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959011, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959063, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959170, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959223, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959280, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959335, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959445, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959552, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959606, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959660, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959715, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959875, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960037, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960410, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960516, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960746, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960810, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961072, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961126, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961232, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961294, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961669, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961773, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961826, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961878, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961984, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962040, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962199, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962251, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962305, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962358, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962412, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962519, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962624, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962730, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962783, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963046, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963100, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963314, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963523, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963628, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963942, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:44", "ts_ms": 1785247964154, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:44", "ts_ms": 1785247964884, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [317.8, 128.5, 110.6], "circle_ratio": 0.4028, "uncertain": false, "reason": "не круг: med=0.403 sec=0.348 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965149, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [317.8, 128.5, 110.6], "circle_ratio": 0.4028, "uncertain": false, "reason": "не круг: med=0.403 sec=0.348 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965205, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [317.8, 128.5, 110.6], "circle_ratio": 0.4028, "uncertain": false, "reason": "не круг: med=0.403 sec=0.348 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965628, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965681, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965731, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965841, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965897, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965949, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966056, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966111, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966162, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966214, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966318, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966371, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966425, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966484, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966536, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966648, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966753, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966858, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967024, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967130, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967182, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967237, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967347, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967400, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967452, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967509, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967565, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967618, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967829, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967940, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967998, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968209, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968316, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968375, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968428, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968480, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968535, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968589, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968643, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968703, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968972, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969096, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969150, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969211, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969383, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969444, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969498, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969549, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969605, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969655, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969818, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969872, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969926, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970126, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970203, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970260, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970323, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970376, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970532, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970740, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970849, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970913, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971080, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971406, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971468, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971522, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971577, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971630, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971683, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971738, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971790, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971948, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972331, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972386, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972443, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972549, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972655, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972710, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972816, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972931, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972986, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973042, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973274, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973391, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973448, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973552, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973613, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973784, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973837, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973895, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973951, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974061, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974168, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974223, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974282, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974335, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974454, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974744, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974882, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974993, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975048, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975103, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975157, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975370, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975424, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975480, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975584, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975642, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975755, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975865, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975918, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975975, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976083, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976136, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976187, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976246, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976301, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976411, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976465, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976518, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976573, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976626, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976978, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977040, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977155, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977427, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977481, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977535, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977587, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977645, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977697, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977750, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977805, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977914, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977968, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978020, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978076, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978129, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978185, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978346, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978400, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978454, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978557, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:39", "ts_ms": 1785248019861, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:39", "ts_ms": 1785248019970, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020021, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020349, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020395, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020661, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020712, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021196, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021244, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021296, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021344, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021394, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021439, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021640, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021694, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080400, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080549, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080595, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080685, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080734, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:14:41", "ts_ms": 1785248081186, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:14:41", "ts_ms": 1785248081236, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:13", "ts_ms": 1785248173367, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [87.5, 49.2, 27.6], "circle_ratio": 0.4132, "uncertain": false, "reason": "не круг: med=0.413 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:13", "ts_ms": 1785248173646, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [87.5, 49.2, 27.6], "circle_ratio": 0.4132, "uncertain": false, "reason": "не круг: med=0.413 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:13", "ts_ms": 1785248173696, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [87.5, 49.2, 27.6], "circle_ratio": 0.4132, "uncertain": false, "reason": "не круг: med=0.413 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:13", "ts_ms": 1785248173747, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [87.5, 49.2, 27.6], "circle_ratio": 0.4132, "uncertain": false, "reason": "не круг: med=0.413 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:14", "ts_ms": 1785248174810, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:14", "ts_ms": 1785248174995, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175093, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175186, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175236, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175281, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175377, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175425, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175471, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175519, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175565, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175885, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175931, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177043, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177091, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177137, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177370, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177415, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177558, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177607, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:18", "ts_ms": 1785248178815, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179047, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179093, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179278, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179373, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179559, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179605, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181095, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181144, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181191, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181331, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181376, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181560, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181606, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181791, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181836, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182112, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182159, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182253, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182303, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182399, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182492, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182540, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182590, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182636, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182729, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182775, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183142, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183192, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183238, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183657, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183704, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183842, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183893, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183940, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:24", "ts_ms": 1785248184080, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:24", "ts_ms": 1785248184127, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:24", "ts_ms": 1785248184175, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:24", "ts_ms": 1785248184882, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [86.0, 51.2, 26.2], "circle_ratio": 0.4928, "uncertain": false, "reason": "не круг: med=0.493 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:25", "ts_ms": 1785248185114, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [86.0, 51.2, 26.2], "circle_ratio": 0.4928, "uncertain": false, "reason": "не круг: med=0.493 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:25", "ts_ms": 1785248185160, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [86.0, 51.2, 26.2], "circle_ratio": 0.4928, "uncertain": false, "reason": "не круг: med=0.493 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:26", "ts_ms": 1785248186520, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.5, 71.6, 27.0], "circle_ratio": 0.3388, "uncertain": false, "reason": "не круг: med=0.339 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:26", "ts_ms": 1785248186827, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.5, 71.6, 27.0], "circle_ratio": 0.3388, "uncertain": false, "reason": "не круг: med=0.339 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:26", "ts_ms": 1785248186875, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.5, 71.6, 27.0], "circle_ratio": 0.3388, "uncertain": false, "reason": "не круг: med=0.339 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:37", "ts_ms": 1785248197641, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [80.0, 60.9, 25.7], "circle_ratio": 0.4589, "uncertain": false, "reason": "не круг: med=0.459 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199474, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199525, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199572, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199620, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199669, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199859, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199904, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200048, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200094, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200191, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200237, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200333, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200383, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200432, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200481, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200625, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200728, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200773, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200833, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200881, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201465, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201512, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201560, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201751, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201797, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201984, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202036, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202086, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202137, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202183, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202283, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202430, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202573, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202623, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202669, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202766, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202818, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202868, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202918, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202965, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203014, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203064, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203112, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203208, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203490, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203536, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203636, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203683, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203732, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203777, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203924, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203972, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:44", "ts_ms": 1785248204024, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:44", "ts_ms": 1785248204121, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:44", "ts_ms": 1785248204401, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205167, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205264, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205310, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205542, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205595, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205881, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205978, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:54", "ts_ms": 1785248214477, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:54", "ts_ms": 1785248214980, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:55", "ts_ms": 1785248215027, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:55", "ts_ms": 1785248215079, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:16:55", "ts_ms": 1785248215133, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:10", "ts_ms": 1785248230685, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [100.5, 64.4, 24.0], "circle_ratio": 0.3657, "uncertain": false, "reason": "не круг: med=0.366 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:11", "ts_ms": 1785248231174, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [100.5, 64.4, 24.0], "circle_ratio": 0.3657, "uncertain": false, "reason": "не круг: med=0.366 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:11", "ts_ms": 1785248231225, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [100.5, 64.4, 24.0], "circle_ratio": 0.3657, "uncertain": false, "reason": "не круг: med=0.366 sec=0.012 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:16", "ts_ms": 1785248236220, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.8, 79.0, 25.8], "circle_ratio": 0.2664, "uncertain": false, "reason": "не круг: med=0.266 sec=0.037 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:16", "ts_ms": 1785248236271, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.8, 79.0, 25.8], "circle_ratio": 0.2664, "uncertain": false, "reason": "не круг: med=0.266 sec=0.037 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:16", "ts_ms": 1785248236324, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.8, 79.0, 25.8], "circle_ratio": 0.2664, "uncertain": false, "reason": "не круг: med=0.266 sec=0.037 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:16", "ts_ms": 1785248236370, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.8, 79.0, 25.8], "circle_ratio": 0.2664, "uncertain": false, "reason": "не круг: med=0.266 sec=0.037 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:36", "ts_ms": 1785248256641, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [80.2, 60.8, 23.0], "circle_ratio": 0.3659, "uncertain": false, "reason": "не круг: med=0.366 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:47", "ts_ms": 1785248267876, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:47", "ts_ms": 1785248267926, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:47", "ts_ms": 1785248267974, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268073, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268118, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268214, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268264, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268314, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268365, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268410, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268459, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268506, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268600, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268695, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268746, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268795, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268890, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268936, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:49", "ts_ms": 1785248269891, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:49", "ts_ms": 1785248269985, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270031, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270078, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270126, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270170, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270216, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270666, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [234.0, 97.3, 44.1], "circle_ratio": 0.6204, "uncertain": false, "reason": "не круг: med=0.620 sec=0.215 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270735, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [234.0, 97.3, 44.1], "circle_ratio": 0.6204, "uncertain": false, "reason": "не круг: med=0.620 sec=0.215 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276282, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276375, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276423, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276605, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276651, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276837, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276883, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277020, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277068, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277116, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277164, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277307, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277353, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277542, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277587, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:11", "ts_ms": 1785248291218, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292087, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292134, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292231, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292283, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292426, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327376, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327426, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327476, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327522, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327666, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327717, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327765, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328306, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328495, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328541, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328636, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328682, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328822, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328867, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328966, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329068, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329119, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329164, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329213, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329310, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329361, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329407, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329457, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329502, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329554, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329599, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329792, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329841, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329892, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329938, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 5, "ts": "2026-07-28T17:21:28", "ts_ms": 1785248488055, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [223.5, 119.1, 56.8], "circle_ratio": 0.4667, "uncertain": false, "reason": "не круг: med=0.467 sec=0.391 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 6, "ts": "2026-07-28T17:21:30", "ts_ms": 1785248490891, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [166.0, 67.7, 62.6], "circle_ratio": 0.8492, "uncertain": false, "reason": "круг: med=0.849 p75=0.856 top=0.844 sec=0.710 strong=17% >= 0.8", "source": "demo"}
|
||||
{"track_id": 6, "ts": "2026-07-28T17:21:46", "ts_ms": 1785248506696, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [180.9, 66.6, 57.0], "circle_ratio": 0.3862, "uncertain": false, "reason": "не круг: med=0.386 sec=0.348 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 14, "ts": "2026-07-28T17:21:49", "ts_ms": 1785248509911, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [181.5, 65.2, 53.0], "circle_ratio": 0.3644, "uncertain": false, "reason": "не круг: med=0.364 sec=0.289 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 16, "ts": "2026-07-28T17:22:50", "ts_ms": 1785248570894, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [116.4, 73.0, 30.0], "circle_ratio": 0.6249, "uncertain": false, "reason": "не круг: med=0.625 sec=0.383 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 17, "ts": "2026-07-28T17:23:02", "ts_ms": 1785248582205, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [180.8, 65.0, 52.0], "circle_ratio": 0.3721, "uncertain": false, "reason": "не круг: med=0.372 sec=0.335 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 19, "ts": "2026-07-28T17:23:07", "ts_ms": 1785248587859, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.1, 72.2, 30.0], "circle_ratio": 0.6361, "uncertain": false, "reason": "не круг: med=0.636 sec=0.404 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 42, "ts": "2026-07-28T17:24:33", "ts_ms": 1785248673969, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [124.0, 78.0, 30.5], "circle_ratio": 0.6206, "uncertain": false, "reason": "не круг: med=0.621 sec=0.480 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 43, "ts": "2026-07-28T17:25:45", "ts_ms": 1785248745349, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [159.0, 67.2, 66.5], "circle_ratio": 0.9183, "uncertain": false, "reason": "круг: med=0.918 p75=0.925 top=0.918 sec=0.699 strong=17% >= 0.8", "source": "demo"}
|
||||
{"track_id": 46, "ts": "2026-07-28T17:26:24", "ts_ms": 1785248784437, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [159.0, 67.2, 66.5], "circle_ratio": 0.9223, "uncertain": false, "reason": "круг: med=0.922 p75=0.930 top=0.922 sec=0.710 strong=25% >= 0.8", "source": "demo"}
|
||||
{"track_id": 48, "ts": "2026-07-28T17:26:54", "ts_ms": 1785248814404, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [114.8, 89.9, 29.0], "circle_ratio": 0.7409, "uncertain": false, "reason": "не круг: med=0.741 sec=0.431 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 52, "ts": "2026-07-28T17:27:27", "ts_ms": 1785248847471, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [114.9, 90.6, 29.0], "circle_ratio": 0.7631, "uncertain": false, "reason": "не круг: med=0.763 sec=0.399 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 53, "ts": "2026-07-28T17:27:35", "ts_ms": 1785248855158, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [115.4, 90.4, 29.0], "circle_ratio": 0.7412, "uncertain": false, "reason": "не круг: med=0.741 sec=0.405 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 56, "ts": "2026-07-28T17:27:53", "ts_ms": 1785248873493, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [115.7, 93.2, 29.0], "circle_ratio": 0.7572, "uncertain": false, "reason": "не круг: med=0.757 sec=0.422 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 62, "ts": "2026-07-28T17:28:21", "ts_ms": 1785248901811, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [115.3, 77.5, 27.0], "circle_ratio": 0.6672, "uncertain": false, "reason": "не круг: med=0.667 sec=0.468 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 64, "ts": "2026-07-28T17:28:36", "ts_ms": 1785248916874, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [131.2, 129.8, 35.3], "circle_ratio": 0.2606, "uncertain": false, "reason": "не круг: med=0.261 sec=0.114 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 71, "ts": "2026-07-28T17:28:42", "ts_ms": 1785248922987, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [132.0, 90.5, 41.1], "circle_ratio": 0.2861, "uncertain": false, "reason": "не круг: med=0.286 sec=0.203 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 107, "ts": "2026-07-28T17:30:46", "ts_ms": 1785249046521, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [170.8, 107.6, 47.0], "circle_ratio": 0.4542, "uncertain": false, "reason": "не круг: med=0.454 sec=0.186 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 106, "ts": "2026-07-28T17:30:51", "ts_ms": 1785249051449, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [140.7, 91.8, 33.2], "circle_ratio": 0.2186, "uncertain": false, "reason": "не круг: med=0.219 sec=0.080 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T17:33:53", "ts_ms": 1785249233659, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.5, 74.3, 28.0], "circle_ratio": 0.6687, "uncertain": false, "reason": "не круг: med=0.669 sec=0.397 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T17:35:04", "ts_ms": 1785249304167, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.4, 79.1, 28.0], "circle_ratio": 0.6869, "uncertain": false, "reason": "не круг: med=0.687 sec=0.361 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 3, "ts": "2026-07-28T17:35:15", "ts_ms": 1785249315975, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [110.6, 81.8, 37.0], "circle_ratio": 0.7137, "uncertain": false, "reason": "не круг: med=0.714 sec=0.374 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 5, "ts": "2026-07-28T17:35:25", "ts_ms": 1785249325707, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [262.9, 150.5, 104.2], "circle_ratio": 0.3802, "uncertain": false, "reason": "не круг: med=0.380 sec=0.351 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 7, "ts": "2026-07-28T17:35:31", "ts_ms": 1785249331386, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [114.1, 70.8, 29.0], "circle_ratio": 0.6109, "uncertain": false, "reason": "не круг: med=0.611 sec=0.474 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 12, "ts": "2026-07-28T17:35:47", "ts_ms": 1785249347253, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [166.0, 69.1, 66.9], "circle_ratio": 0.8663, "uncertain": false, "reason": "круг: med=0.866 p75=0.880 top=0.866 sec=0.591 strong=0% >= 0.8", "source": "demo"}
|
||||
{"track_id": 13, "ts": "2026-07-28T17:36:14", "ts_ms": 1785249374567, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [114.1, 71.2, 28.0], "circle_ratio": 0.6187, "uncertain": false, "reason": "не круг: med=0.619 sec=0.456 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 22, "ts": "2026-07-28T17:38:51", "ts_ms": 1785249531777, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [201.5, 81.5, 33.7], "circle_ratio": 0.4074, "uncertain": false, "reason": "не круг: med=0.407 sec=0.213 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 34, "ts": "2026-07-28T17:39:44", "ts_ms": 1785249584407, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [289.8, 233.5, 164.3], "circle_ratio": 0.5186, "uncertain": false, "reason": "не круг: med=0.519 sec=0.493 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 38, "ts": "2026-07-28T17:39:46", "ts_ms": 1785249586344, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [263.0, 79.0, 28.8], "circle_ratio": 0.4856, "uncertain": false, "reason": "не круг: med=0.486 sec=0.404 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 37, "ts": "2026-07-28T17:39:48", "ts_ms": 1785249588100, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [144.0, 55.3, 46.2], "circle_ratio": 0.6627, "uncertain": false, "reason": "не круг: med=0.663 sec=0.270 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 39, "ts": "2026-07-28T17:39:54", "ts_ms": 1785249594385, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [257.0, 52.6, 34.0], "circle_ratio": 0.6318, "uncertain": false, "reason": "не круг: med=0.632 sec=0.259 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 40, "ts": "2026-07-28T17:39:55", "ts_ms": 1785249595702, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [294.0, 230.0, 171.1], "circle_ratio": 0.5363, "uncertain": false, "reason": "не круг: med=0.536 sec=0.458 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 55, "ts": "2026-07-28T17:40:25", "ts_ms": 1785249625885, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.0, 27.0], "circle_ratio": 0.6289, "uncertain": false, "reason": "не круг: med=0.629 sec=0.475 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T17:44:06", "ts_ms": 1785249846381, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [111.0, 71.2, 27.0], "circle_ratio": 0.6391, "uncertain": false, "reason": "не круг: med=0.639 sec=0.471 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T17:47:47", "ts_ms": 1785250067275, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.9, 72.5, 27.0], "circle_ratio": 0.6322, "uncertain": false, "reason": "не круг: med=0.632 sec=0.480 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 2, "ts": "2026-07-28T17:47:57", "ts_ms": 1785250077360, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [157.0, 70.9, 67.3], "circle_ratio": 0.8678, "uncertain": false, "reason": "круг: med=0.868 p75=0.889 top=0.863 sec=0.795 strong=50% >= 0.8", "source": "demo"}
|
||||
{"track_id": 3, "ts": "2026-07-28T17:48:15", "ts_ms": 1785250095948, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [158.0, 69.2, 66.6], "circle_ratio": 0.8646, "uncertain": false, "reason": "круг: med=0.865 p75=0.887 top=0.865 sec=0.647 strong=17% >= 0.8", "source": "demo"}
|
||||
{"track_id": 2, "ts": "2026-07-28T17:51:16", "ts_ms": 1785250276251, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [111.4, 72.8, 27.0], "circle_ratio": 0.6383, "uncertain": false, "reason": "не круг: med=0.638 sec=0.476 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T17:51:16", "ts_ms": 1785250276438, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [158.0, 68.4, 66.6], "circle_ratio": 0.8829, "uncertain": false, "reason": "круг: med=0.883 p75=0.909 top=0.883 sec=0.649 strong=17% >= 0.8", "source": "demo"}
|
||||
{"track_id": 2, "ts": "2026-07-28T17:53:05", "ts_ms": 1785250385585, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.0, 71.3, 27.0], "circle_ratio": 0.6264, "uncertain": false, "reason": "не круг: med=0.626 sec=0.476 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T17:53:05", "ts_ms": 1785250385762, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [158.0, 67.3, 66.0], "circle_ratio": 0.9261, "uncertain": false, "reason": "круг: med=0.926 p75=0.926 top=0.926 sec=0.537 strong=8% >= 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T17:58:13", "ts_ms": 1785250693238, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [158.0, 67.4, 66.3], "circle_ratio": 0.9117, "uncertain": false, "reason": "круг: med=0.912 p75=0.918 top=0.912 sec=0.875 strong=83% >= 0.8", "source": "demo"}
|
||||
{"track_id": 2, "ts": "2026-07-28T17:58:13", "ts_ms": 1785250693405, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [111.8, 71.7, 27.0], "circle_ratio": 0.6342, "uncertain": false, "reason": "не круг: med=0.634 sec=0.486 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 7, "ts": "2026-07-28T17:58:42", "ts_ms": 1785250722062, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [199.8, 134.5, 126.0], "circle_ratio": 0.5803, "uncertain": false, "reason": "не круг: med=0.580 sec=0.262 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 8, "ts": "2026-07-28T17:58:48", "ts_ms": 1785250728812, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [196.0, 68.2, 64.5], "circle_ratio": 0.8816, "uncertain": false, "reason": "круг: med=0.882 p75=0.889 top=0.882 sec=0.593 strong=33% >= 0.8", "source": "demo"}
|
||||
{"track_id": 11, "ts": "2026-07-28T17:59:19", "ts_ms": 1785250759939, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [264.0, 30.6, 16.7], "circle_ratio": 0.5504, "uncertain": false, "reason": "не круг: med=0.550 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 19, "ts": "2026-07-28T18:01:29", "ts_ms": 1785250889343, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [132.0, 99.5, 73.8], "circle_ratio": 0.634, "uncertain": false, "reason": "не круг: med=0.634 sec=0.369 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 21, "ts": "2026-07-28T18:01:41", "ts_ms": 1785250901079, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [162.0, 78.7, 69.2], "circle_ratio": 0.7707, "uncertain": false, "reason": "не круг: med=0.771 sec=0.536 strong=8% < 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T18:05:03", "ts_ms": 1785251103971, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [195.0, 71.4, 67.9], "circle_ratio": 0.8496, "uncertain": false, "reason": "круг: med=0.850 p75=0.850 top=0.829 sec=0.725 strong=50% >= 0.8", "source": "demo"}
|
||||
{"track_id": 2, "ts": "2026-07-28T18:05:07", "ts_ms": 1785251107380, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [162.0, 74.2, 70.5], "circle_ratio": 0.7413, "uncertain": false, "reason": "не круг: med=0.741 sec=0.598 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 5, "ts": "2026-07-28T18:05:35", "ts_ms": 1785251135664, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [115.8, 78.0, 30.0], "circle_ratio": 0.6635, "uncertain": false, "reason": "не круг: med=0.663 sec=0.443 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 10, "ts": "2026-07-28T18:06:17", "ts_ms": 1785251177171, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [183.0, 67.8, 48.5], "circle_ratio": 0.6256, "uncertain": false, "reason": "не круг: med=0.626 sec=0.465 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 11, "ts": "2026-07-28T18:07:07", "ts_ms": 1785251227738, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [161.0, 67.5, 64.0], "circle_ratio": 0.9018, "uncertain": false, "reason": "круг: med=0.902 p75=0.914 top=0.902 sec=0.329 strong=0% >= 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T18:09:48", "ts_ms": 1785251388160, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [169.1, 143.5, 0.0], "circle_ratio": 0.6503, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 3, "ts": "2026-07-28T18:10:00", "ts_ms": 1785251400844, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [468.5, 78.4, 0.0], "circle_ratio": 0.1729, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 4, "ts": "2026-07-28T18:10:01", "ts_ms": 1785251401838, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [193.6, 83.4, 0.0], "circle_ratio": 0.4252, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 9, "ts": "2026-07-28T18:10:01", "ts_ms": 1785251401838, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [186.8, 106.4, 16.7], "circle_ratio": 0.2125, "uncertain": false, "reason": "не круг: med=0.212 sec=0.202 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 7, "ts": "2026-07-28T18:10:02", "ts_ms": 1785251402341, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [167.0, 96.5, 71.5], "circle_ratio": 0.6402, "uncertain": false, "reason": "не круг: med=0.640 sec=0.398 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 4, "ts": "2026-07-28T18:10:17", "ts_ms": 1785251417745, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [167.0, 65.9, 65.2], "circle_ratio": 0.8773, "uncertain": false, "reason": "круг: med=0.877 p75=0.894 top=0.877 sec=0.659 strong=25% >= 0.8", "source": "demo"}
|
||||
{"track_id": 6, "ts": "2026-07-28T18:10:18", "ts_ms": 1785251418644, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [160.9, 80.6, 0.0], "circle_ratio": 0.4968, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 16, "ts": "2026-07-28T18:10:45", "ts_ms": 1785251445863, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [107.5, 34.8, 0.0], "circle_ratio": 0.3281, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 20, "ts": "2026-07-28T18:10:47", "ts_ms": 1785251447110, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [162.7, 104.5, 0.0], "circle_ratio": 0.5509, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 21, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481084, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [72.2, 37.5, 34.9], "circle_ratio": 0.4618, "uncertain": false, "reason": "не круг: med=0.462 sec=0.226 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 22, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481084, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [192.8, 110.8, 68.0], "circle_ratio": 0.5676, "uncertain": false, "reason": "не круг: med=0.568 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 23, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481085, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [122.4, 62.8, 0.0], "circle_ratio": 0.4646, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 24, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481085, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [137.2, 31.9, 0.0], "circle_ratio": 0.2345, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 25, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481086, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [103.7, 47.0, 31.9], "circle_ratio": 0.3144, "uncertain": false, "reason": "не круг: med=0.314 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 29, "ts": "2026-07-28T18:11:59", "ts_ms": 1785251519486, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [86.5, 54.9, 0.0], "circle_ratio": 0.595, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 28, "ts": "2026-07-28T18:12:00", "ts_ms": 1785251520717, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [89.6, 62.3, 0.0], "circle_ratio": 0.6456, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 30, "ts": "2026-07-28T18:12:13", "ts_ms": 1785251533754, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [105.2, 71.6, 52.0], "circle_ratio": 0.5827, "uncertain": false, "reason": "не круг: med=0.583 sec=0.000 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 5, "ts": "2026-07-28T18:12:58", "ts_ms": 1785251578253, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [167.0, 68.5, 66.5], "circle_ratio": 0.902, "uncertain": false, "reason": "круг: med=0.902 p75=0.909 top=0.902 sec=0.574 strong=0% >= 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T18:13:14", "ts_ms": 1785251594940, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [156.6, 78.5, 1.0], "circle_ratio": 0.4847, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 6, "ts": "2026-07-28T18:13:20", "ts_ms": 1785251600288, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [126.3, 85.1, 1.0], "circle_ratio": 0.5677, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 10, "ts": "2026-07-28T18:13:40", "ts_ms": 1785251620975, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [59.9, 43.0, 1.0], "circle_ratio": 0.5168, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 27, "ts": "2026-07-28T18:14:27", "ts_ms": 1785251667925, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [362.4, 140.9, 1.0], "circle_ratio": 0.3353, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 26, "ts": "2026-07-28T18:15:02", "ts_ms": 1785251702136, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [150.8, 106.5, 37.4], "circle_ratio": 0.2563, "uncertain": false, "reason": "не круг: med=0.256 sec=0.244 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 37, "ts": "2026-07-28T18:15:02", "ts_ms": 1785251702537, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [103.4, 61.4, 7.0], "circle_ratio": 0.6081, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 38, "ts": "2026-07-28T18:15:02", "ts_ms": 1785251702740, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [97.6, 28.9, 8.0], "circle_ratio": 0.3021, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 6, "ts": "2026-07-28T18:39:25", "ts_ms": 1785253165067, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [166.0, 69.6, 62.4], "circle_ratio": 0.8589, "uncertain": false, "reason": "круг: med=0.859 p75=0.864 top=0.859 sec=0.355 strong=0% >= 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T18:42:41", "ts_ms": 1785253361213, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [97.8, 94.8, 37.2], "circle_ratio": 0.4546, "uncertain": false, "reason": "не круг: med=0.455 sec=0.380 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 3, "ts": "2026-07-28T18:42:41", "ts_ms": 1785253361214, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [102.4, 22.9, 10.0], "circle_ratio": 0.2332, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 4, "ts": "2026-07-28T18:42:41", "ts_ms": 1785253361214, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [42.8, 31.4, 9.5], "circle_ratio": 0.6757, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 2, "ts": "2026-07-28T18:42:44", "ts_ms": 1785253364281, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [54.8, 33.5, 7.0], "circle_ratio": 0.5549, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 6, "ts": "2026-07-28T18:42:50", "ts_ms": 1785253370683, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [47.7, 32.6, 11.0], "circle_ratio": 0.6536, "uncertain": true, "reason": "НЕУВЕРЕННО → безопасная зона: нет консенсуса 45 кадров; голоса B:11 C:1; сторона 11 мм у минимума 10", "source": "demo"}
|
||||
{"track_id": 15, "ts": "2026-07-28T18:43:30", "ts_ms": 1785253410234, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [167.0, 67.6, 66.2], "circle_ratio": 0.9095, "uncertain": false, "reason": "круг: med=0.909 p75=0.925 top=0.909 sec=0.413 strong=0% >= 0.8", "source": "demo"}
|
||||
{"track_id": 16, "ts": "2026-07-28T18:43:31", "ts_ms": 1785253411985, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [86.5, 51.3, 2.0], "circle_ratio": 0.55, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 2, "ts": "2026-07-28T18:46:01", "ts_ms": 1785253561603, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [200.0, 84.1, 66.1], "circle_ratio": 0.89, "uncertain": false, "reason": "круг: med=0.890 p75=0.897 top=0.763 sec=0.890 strong=92% >= 0.8", "source": "demo"}
|
||||
{"track_id": 4, "ts": "2026-07-28T18:46:13", "ts_ms": 1785253573851, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [167.0, 71.8, 64.2], "circle_ratio": 0.8484, "uncertain": false, "reason": "круг: med=0.848 p75=0.855 top=0.848 sec=0.585 strong=0% >= 0.8", "source": "demo"}
|
||||
{"track_id": 6, "ts": "2026-07-28T18:46:25", "ts_ms": 1785253585551, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [97.9, 73.4, 31.0], "circle_ratio": 0.698, "uncertain": false, "reason": "не круг: med=0.698 sec=0.393 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 11, "ts": "2026-07-28T18:51:13", "ts_ms": 1785253873589, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [206.3, 166.0, 7.7], "circle_ratio": 0.0474, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T18:58:33", "ts_ms": 1785254313543, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [201.0, 78.6, 68.5], "circle_ratio": 0.8635, "uncertain": false, "reason": "круг: med=0.863 p75=0.911 top=0.816 sec=0.757 strong=50% >= 0.8", "source": "demo"}
|
||||
{"track_id": 5, "ts": "2026-07-28T18:58:56", "ts_ms": 1785254336468, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [201.0, 80.8, 66.5], "circle_ratio": 0.7914, "uncertain": false, "reason": "не круг: med=0.791 sec=0.524 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 8, "ts": "2026-07-28T18:59:15", "ts_ms": 1785254355875, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [165.0, 86.0, 63.6], "circle_ratio": 0.851, "uncertain": false, "reason": "круг: med=0.851 p75=0.870 top=0.707 sec=0.851 strong=75% >= 0.8", "source": "demo"}
|
||||
{"track_id": 10, "ts": "2026-07-28T18:59:33", "ts_ms": 1785254373589, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [201.0, 90.2, 69.2], "circle_ratio": 0.7267, "uncertain": false, "reason": "не круг: med=0.727 sec=0.536 strong=17% < 0.8", "source": "demo"}
|
||||
{"track_id": 13, "ts": "2026-07-28T18:59:39", "ts_ms": 1785254379729, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [167.0, 87.3, 64.5], "circle_ratio": 0.7114, "uncertain": false, "reason": "не круг: med=0.711 sec=0.605 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T19:03:15", "ts_ms": 1785254595565, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [201.0, 81.8, 77.6], "circle_ratio": 0.822, "uncertain": false, "reason": "круг: med=0.822 p75=0.909 top=0.759 sec=0.704 strong=50% >= 0.8", "source": "demo"}
|
||||
{"track_id": 5, "ts": "2026-07-28T19:03:34", "ts_ms": 1785254614891, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [176.5, 43.1, 16.0], "circle_ratio": 0.2907, "uncertain": false, "reason": "не круг: med=0.291 sec=0.291 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 1, "ts": "2026-07-28T19:06:34", "ts_ms": 1785254794513, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [164.0, 67.6, 65.5], "circle_ratio": 0.9175, "uncertain": false, "reason": "круг: med=0.917 p75=0.920 top=0.917 sec=0.430 strong=17% >= 0.8", "source": "demo"}
|
||||
{"track_id": 2, "ts": "2026-07-28T19:06:37", "ts_ms": 1785254797835, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [88.1, 47.0, 40.8], "circle_ratio": 0.4338, "uncertain": false, "reason": "не круг: med=0.434 sec=0.298 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 4, "ts": "2026-07-28T19:06:46", "ts_ms": 1785254806783, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [164.0, 66.9, 66.2], "circle_ratio": 0.9188, "uncertain": false, "reason": "круг: med=0.919 p75=0.925 top=0.919 sec=0.351 strong=0% >= 0.8", "source": "demo"}
|
||||
{"track_id": 6, "ts": "2026-07-28T19:06:57", "ts_ms": 1785254817413, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [183.0, 161.4, 95.1], "circle_ratio": 0.5757, "uncertain": false, "reason": "не круг: med=0.576 sec=0.287 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 8, "ts": "2026-07-28T19:07:24", "ts_ms": 1785254844930, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [162.0, 75.6, 71.9], "circle_ratio": 0.7303, "uncertain": false, "reason": "не круг: med=0.730 sec=0.619 strong=17% < 0.8", "source": "demo"}
|
||||
{"track_id": 9, "ts": "2026-07-28T19:07:34", "ts_ms": 1785254854571, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [54.4, 42.5, 24.2], "circle_ratio": 0.4419, "uncertain": false, "reason": "не круг: med=0.442 sec=0.239 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 11, "ts": "2026-07-28T19:08:24", "ts_ms": 1785254904478, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [196.0, 67.9, 60.7], "circle_ratio": 0.8855, "uncertain": false, "reason": "круг: med=0.885 p75=0.888 top=0.885 sec=0.453 strong=0% >= 0.8", "source": "demo"}
|
||||
{"track_id": 12, "ts": "2026-07-28T19:08:43", "ts_ms": 1785254923363, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [117.4, 73.2, 26.0], "circle_ratio": 0.6211, "uncertain": false, "reason": "не круг: med=0.621 sec=0.323 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": 16, "ts": "2026-07-28T19:09:31", "ts_ms": 1785254971629, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [93.9, 69.5, 14.0], "circle_ratio": 0.6304, "uncertain": false, "reason": "не круг: med=0.630 sec=0.248 strong=0% < 0.8", "source": "demo"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:12:54", "ts_ms": 1785679974276, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [517.1, 310.7, 63.0], "circle_ratio": 0.5169, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:12:56", "ts_ms": 1785679976189, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [506.7, 162.8, 63.0], "circle_ratio": 0.3102, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:13:02", "ts_ms": 1785679982132, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [514.2, 167.6, 63.0], "circle_ratio": 0.313, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:13:24", "ts_ms": 1785680004914, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [511.3, 163.0, 63.0], "circle_ratio": 0.3138, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:19:16", "ts_ms": 1785680356388, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [173.0, 66.5, 65.5], "circle_ratio": 0.9098, "uncertain": false, "reason": "круг: med=0.910 p75=0.912 top=0.910 sec=0.000 strong=0% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:19:17", "ts_ms": 1785680357229, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [111.7, 97.8, 95.5], "circle_ratio": 0.7744, "uncertain": false, "reason": "не круг: med=0.774 sec=0.134 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:19:17", "ts_ms": 1785680357697, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [173.0, 66.3, 65.6], "circle_ratio": 0.9091, "uncertain": false, "reason": "круг: med=0.909 p75=0.911 top=0.909 sec=0.000 strong=0% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:19:19", "ts_ms": 1785680359211, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [177.2, 132.3, 17.5], "circle_ratio": 0.5422, "uncertain": false, "reason": "не круг: med=0.542 sec=0.227 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:21:19", "ts_ms": 1785680480000, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [173.0, 66.0, 65.6], "circle_ratio": 0.9254, "uncertain": false, "reason": "круг: med=0.925 p75=0.930 top=0.925 sec=0.000 strong=0% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:25:16", "ts_ms": 1785680716591, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [242.0, 173.0, 101.4], "circle_ratio": 0.5338, "uncertain": false, "reason": "не круг: med=0.534 sec=0.424 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:27:38", "ts_ms": 1785680858442, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [209.5, 79.4, 55.3], "circle_ratio": 0.5325, "uncertain": false, "reason": "не круг: med=0.533 sec=0.316 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:28:12", "ts_ms": 1785680892822, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [286.8, 123.5, 115.0], "circle_ratio": 0.4296, "uncertain": false, "reason": "не круг: med=0.430 sec=0.422 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:49:09", "ts_ms": 1785682149066, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [251.7, 115.1, 113.0], "circle_ratio": 0.4398, "uncertain": false, "reason": "не круг: med=0.440 sec=0.410 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T21:58:32", "ts_ms": 1785682712615, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [342.4, 161.5, 89.4], "circle_ratio": 0.2802, "uncertain": false, "reason": "не круг: med=0.280 sec=0.247 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:04:17", "ts_ms": 1785683057585, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 337.6, 133.0], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:20:32", "ts_ms": 1785684032190, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 248.5, 108.1], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:20:36", "ts_ms": 1785684036590, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [172.0, 66.3, 65.6], "circle_ratio": 0.9237, "uncertain": false, "reason": "круг: med=0.924 p75=0.926 top=0.924 sec=0.000 strong=17% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:22:50", "ts_ms": 1785684170019, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 277.0, 71.8], "circle_ratio": 0.394, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:22:54", "ts_ms": 1785684174185, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [172.0, 67.2, 66.1], "circle_ratio": 0.9154, "uncertain": false, "reason": "круг: med=0.915 p75=0.919 top=0.915 sec=0.000 strong=0% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:23:49", "ts_ms": 1785684229059, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 235.5, 97.4], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:35:41", "ts_ms": 1785684941178, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [254.7, 80.2, 67.0], "circle_ratio": 0.3161, "uncertain": false, "reason": "не круг: med=0.316 sec=0.204 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:46:12", "ts_ms": 1785685572325, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [241.9, 116.9, 73.5], "circle_ratio": 0.4709, "uncertain": false, "reason": "не круг: med=0.471 sec=0.392 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:46:12", "ts_ms": 1785685572795, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 216.9, 65.0], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:46:13", "ts_ms": 1785685573398, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [254.0, 109.1, 77.0], "circle_ratio": 0.4083, "uncertain": false, "reason": "не круг: med=0.408 sec=0.225 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:49:22", "ts_ms": 1785685762401, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [183.6, 167.2, 52.0], "circle_ratio": 0.6645, "uncertain": false, "reason": "не круг: med=0.664 sec=0.221 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:49:30", "ts_ms": 1785685770563, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [72.1, 66.0, 24.4], "circle_ratio": 0.3429, "uncertain": false, "reason": "не круг: med=0.343 sec=0.297 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:50:10", "ts_ms": 1785685810145, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [68.5, 39.1, 22.0], "circle_ratio": 0.5736, "uncertain": false, "reason": "не круг: med=0.574 sec=0.260 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:51:59", "ts_ms": 1785685919615, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [69.0, 55.4, 21.0], "circle_ratio": 0.6302, "uncertain": false, "reason": "не круг: med=0.630 sec=0.170 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T22:54:22", "ts_ms": 1785686062886, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.0, 53.8, 21.0], "circle_ratio": 0.5805, "uncertain": false, "reason": "не круг: med=0.580 sec=0.236 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:29:06", "ts_ms": 1785688146105, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [66.4, 55.4, 21.0], "circle_ratio": 0.563, "uncertain": false, "reason": "не круг: med=0.563 sec=0.151 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:29:06", "ts_ms": 1785688146710, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [110.3, 63.1, 21.0], "circle_ratio": 0.5844, "uncertain": false, "reason": "не круг: med=0.584 sec=0.187 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:29:15", "ts_ms": 1785688155513, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [76.7, 59.0, 21.0], "circle_ratio": 0.5275, "uncertain": false, "reason": "не круг: med=0.527 sec=0.152 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:29:35", "ts_ms": 1785688175908, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 56.3, 21.0], "circle_ratio": 0.5069, "uncertain": false, "reason": "не круг: med=0.507 sec=0.134 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:31:21", "ts_ms": 1785688281179, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [66.6, 28.9, 23.0], "circle_ratio": 0.4759, "uncertain": false, "reason": "не круг: med=0.476 sec=0.452 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:35:46", "ts_ms": 1785688546219, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [124.8, 46.5, 22.0], "circle_ratio": 0.3655, "uncertain": false, "reason": "не круг: med=0.365 sec=0.133 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:35:50", "ts_ms": 1785688550318, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [119.4, 52.3, 22.0], "circle_ratio": 0.4207, "uncertain": false, "reason": "не круг: med=0.421 sec=0.145 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:35:51", "ts_ms": 1785688551695, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.8, 33.2, 22.0], "circle_ratio": 0.4346, "uncertain": false, "reason": "не круг: med=0.435 sec=0.290 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:54:47", "ts_ms": 1785689687830, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [93.7, 30.0, 22.0], "circle_ratio": 0.3504, "uncertain": false, "reason": "не круг: med=0.350 sec=0.186 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:54:51", "ts_ms": 1785689691122, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.5, 41.8, 22.0], "circle_ratio": 0.3626, "uncertain": false, "reason": "не круг: med=0.363 sec=0.159 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-02T23:54:53", "ts_ms": 1785689693104, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [80.1, 29.2, 21.5], "circle_ratio": 0.4371, "uncertain": false, "reason": "не круг: med=0.437 sec=0.327 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:06:08", "ts_ms": 1785690368310, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 240.0, 105.8], "circle_ratio": 0.3071, "uncertain": false, "reason": "объект обрезан краем кадра → габарит неполный, считаем негабаритом", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:46:18", "ts_ms": 1785692778248, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.3, 55.6, 53.0], "circle_ratio": 0.6405, "uncertain": false, "reason": "не круг: med=0.641 sec=0.399 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:46:25", "ts_ms": 1785692785439, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [77.1, 70.0, 54.4], "circle_ratio": 0.6187, "uncertain": false, "reason": "не круг: med=0.619 sec=0.438 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:46:29", "ts_ms": 1785692789873, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [76.9, 71.5, 25.9], "circle_ratio": 0.3387, "uncertain": false, "reason": "не круг: med=0.339 sec=0.108 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:46:46", "ts_ms": 1785692806035, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [119.5, 56.8, 47.9], "circle_ratio": 0.6865, "uncertain": false, "reason": "не круг: med=0.686 sec=0.212 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:46:47", "ts_ms": 1785692807010, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [169.0, 68.6, 65.3], "circle_ratio": 0.8745, "uncertain": false, "reason": "круг: med=0.874 p75=0.899 top=0.874 sec=0.405 strong=8% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:46:49", "ts_ms": 1785692809570, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [126.0, 48.6, 34.2], "circle_ratio": 0.6214, "uncertain": false, "reason": "не круг: med=0.621 sec=0.243 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:46:52", "ts_ms": 1785692812625, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 204.4, 97.0], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:46:53", "ts_ms": 1785692813095, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [285.5, 204.4, 97.0], "circle_ratio": 0.6026, "uncertain": false, "reason": "не круг: med=0.603 sec=0.185 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:46:57", "ts_ms": 1785692817697, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [207.0, 204.5, 60.5], "circle_ratio": 0.9458, "uncertain": false, "reason": "круг: med=0.946 p75=0.952 top=0.946 sec=0.155 strong=0% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:47:00", "ts_ms": 1785692820148, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [204.8, 120.8, 70.0], "circle_ratio": 0.5863, "uncertain": false, "reason": "не круг: med=0.586 sec=0.345 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:48:01", "ts_ms": 1785692881972, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [132.0, 59.4, 51.6], "circle_ratio": 0.6828, "uncertain": false, "reason": "не круг: med=0.683 sec=0.295 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:48:02", "ts_ms": 1785692882612, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [168.0, 70.4, 66.4], "circle_ratio": 0.8631, "uncertain": false, "reason": "круг: med=0.863 p75=0.876 top=0.855 sec=0.735 strong=42% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:48:05", "ts_ms": 1785692885397, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [142.5, 48.4, 29.5], "circle_ratio": 0.5428, "uncertain": false, "reason": "не круг: med=0.543 sec=0.270 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:48:09", "ts_ms": 1785692889600, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [76.5, 71.5, 57.9], "circle_ratio": 0.6528, "uncertain": false, "reason": "не круг: med=0.653 sec=0.452 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:49:06", "ts_ms": 1785692946351, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 117.0, 45.2], "circle_ratio": 0.1306, "uncertain": false, "reason": "объект обрезан краем кадра → габарит неполный, считаем негабаритом", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:49:45", "ts_ms": 1785692985263, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 190.5, 161.5], "circle_ratio": 0.4301, "uncertain": false, "reason": "объект обрезан краем кадра → габарит неполный, считаем негабаритом", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:50:18", "ts_ms": 1785693018054, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 113.5, 43.9], "circle_ratio": 0.1363, "uncertain": false, "reason": "объект обрезан краем кадра → габарит неполный, считаем негабаритом", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:50:25", "ts_ms": 1785693025762, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [86.5, 75.3, 72.0], "circle_ratio": 0.6856, "uncertain": false, "reason": "не круг: med=0.686 sec=0.363 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:50:45", "ts_ms": 1785693045940, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [55.0, 49.2, 26.3], "circle_ratio": 0.5988, "uncertain": false, "reason": "не круг: med=0.599 sec=0.221 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:50:47", "ts_ms": 1785693047050, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [81.0, 50.1, 48.4], "circle_ratio": 0.8419, "uncertain": false, "reason": "круг: med=0.842 p75=0.855 top=0.842 sec=0.528 strong=0% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:50:51", "ts_ms": 1785693051014, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.1, 46.2], "circle_ratio": 0.6391, "uncertain": false, "reason": "не круг: med=0.639 sec=0.331 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:50:51", "ts_ms": 1785693051992, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [179.0, 48.6, 47.1], "circle_ratio": 0.9012, "uncertain": false, "reason": "круг: med=0.901 p75=0.909 top=0.901 sec=0.515 strong=0% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:50:54", "ts_ms": 1785693054037, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [158.5, 50.1, 46.1], "circle_ratio": 0.5833, "uncertain": false, "reason": "не круг: med=0.583 sec=0.326 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:50:57", "ts_ms": 1785693057600, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [172.9, 61.0, 54.8], "circle_ratio": 0.4696, "uncertain": false, "reason": "не круг: med=0.470 sec=0.470 strong=0% < 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:50:59", "ts_ms": 1785693059249, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [205.8, 205.0, 61.0], "circle_ratio": 0.9515, "uncertain": false, "reason": "круг: med=0.952 p75=0.952 top=0.952 sec=0.187 strong=0% >= 0.8", "source": "main"}
|
||||
{"track_id": null, "ts": "2026-08-03T00:51:01", "ts_ms": 1785693061632, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [205.3, 117.9, 70.0], "circle_ratio": 0.5758, "uncertain": false, "reason": "не круг: med=0.576 sec=0.329 strong=0% < 0.8", "source": "main"}
|
||||
320
vision_classifier/main.py
Executable file
320
vision_classifier/main.py
Executable file
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Алгоритмическая часть трека 3: Intel RealSense D415 на Orange PI.
|
||||
|
||||
Пайплайн:
|
||||
depth+color → сегментация объекта на ленте → габариты L×W×H + circle_ratio
|
||||
→ классификация (B/C/D) → MQTT → сервоприводы Arduino (без правок arduino_code).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import cv2
|
||||
import yaml
|
||||
|
||||
from camera import RealSenseV4L2, depth_colormap
|
||||
from classify import Category, ClassificationResult
|
||||
from journal import append_decision
|
||||
from measure import measure_flat_object, measure_object, segment_objects, segment_rgb_objects, is_plausible_measurement
|
||||
from mqtt_bridge import MqttBridge
|
||||
from stabilize import DecisionStabilizer
|
||||
|
||||
ZONE_TO_CATEGORY = {
|
||||
"B": Category.SUITABLE,
|
||||
"C": Category.OVERSIZE,
|
||||
"D": Category.NEED_PACK,
|
||||
}
|
||||
|
||||
|
||||
def load_config(path: Path) -> Dict[str, Any]:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def draw_overlay(
|
||||
color_bgr,
|
||||
depth_mm,
|
||||
measurement,
|
||||
result: Optional[ClassificationResult],
|
||||
belt_mm: float,
|
||||
):
|
||||
vis = color_bgr.copy()
|
||||
depth_vis = depth_colormap(depth_mm)
|
||||
if measurement is not None:
|
||||
cv2.drawContours(vis, [measurement.contour], -1, (0, 255, 0), 2)
|
||||
cx, cy = measurement.centroid_px
|
||||
cv2.circle(vis, (cx, cy), 4, (0, 0, 255), -1)
|
||||
lines = [
|
||||
f"L={measurement.length_mm:.0f} W={measurement.width_mm:.0f} H={measurement.height_mm:.0f} mm",
|
||||
f"circle_ratio={measurement.circle_ratio:.3f}",
|
||||
]
|
||||
if result is not None:
|
||||
lines.append(f"{result.category.zone}: {result.category.ru_label}")
|
||||
y = 24
|
||||
for line in lines:
|
||||
cv2.putText(vis, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (20, 20, 20), 3, cv2.LINE_AA)
|
||||
cv2.putText(vis, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 1, cv2.LINE_AA)
|
||||
y += 22
|
||||
cv2.putText(
|
||||
vis,
|
||||
f"belt={belt_mm:.0f}mm",
|
||||
(10, vis.shape[0] - 12),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.5,
|
||||
(200, 200, 200),
|
||||
1,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
return vis, depth_vis
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="RealSense D415 classifier for hackathon track 3")
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
default=str(Path(__file__).with_name("config.yaml")),
|
||||
help="Путь к config.yaml",
|
||||
)
|
||||
parser.add_argument("--once", action="store_true", help="Один кадр и выход")
|
||||
parser.add_argument("--no-mqtt", action="store_true", help="Не публиковать в MQTT")
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
action="store_true",
|
||||
help="Живое превью в debug_frames/live_*.jpg (без GTK-окон)",
|
||||
)
|
||||
parser.add_argument("--dry-route", action="store_true", help="Не двигать серво")
|
||||
parser.add_argument("--no-motor", action="store_true", help="Не включать шаговик ленты")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = load_config(Path(args.config))
|
||||
cam_cfg = cfg["camera"]
|
||||
cls_cfg = cfg["classification"]
|
||||
rt = cfg.get("runtime", {})
|
||||
mqtt_cfg = dict(cfg.get("mqtt", {}))
|
||||
routing_cfg = dict(cfg.get("routing", {}))
|
||||
motor_cfg = dict(cfg.get("motor", {}))
|
||||
if args.no_mqtt:
|
||||
mqtt_cfg["enabled"] = False
|
||||
if args.dry_route:
|
||||
routing_cfg["enabled"] = False
|
||||
if args.no_motor:
|
||||
motor_cfg["enabled"] = False
|
||||
mqtt_cfg["_routing"] = routing_cfg
|
||||
mqtt_cfg["_motor"] = motor_cfg
|
||||
|
||||
show_preview = args.preview or bool(rt.get("show_preview", False))
|
||||
save_debug = bool(rt.get("save_debug_frames", False)) or show_preview
|
||||
debug_dir = Path(rt.get("debug_dir", "debug_frames"))
|
||||
if save_debug or show_preview:
|
||||
debug_dir.mkdir(parents=True, exist_ok=True)
|
||||
live_color = debug_dir / "live_color.jpg"
|
||||
live_depth = debug_dir / "live_depth.jpg"
|
||||
preview_every = max(1, int(rt.get("preview_every_n", 3)))
|
||||
|
||||
print("[vision] открываю RealSense D415…")
|
||||
cam = RealSenseV4L2(
|
||||
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
|
||||
color_device=cam_cfg.get("color_device", "/dev/video4"),
|
||||
width=int(cam_cfg.get("width", 640)),
|
||||
height=int(cam_cfg.get("height", 480)),
|
||||
fps=int(cam_cfg.get("fps", 30)),
|
||||
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
|
||||
use_color=bool(cfg.get("use_color", False)),
|
||||
)
|
||||
|
||||
belt_mm = float(cfg.get("belt_distance_mm") or 0)
|
||||
if belt_mm <= 0:
|
||||
print("[vision] калибровка плоскости ленты (уберите объекты)…")
|
||||
belt_mm = cam.estimate_belt_distance_mm()
|
||||
print(f"[vision] belt_distance_mm ≈ {belt_mm:.1f}")
|
||||
else:
|
||||
print(f"[vision] belt_distance_mm из конфига: {belt_mm:.1f}")
|
||||
|
||||
background = None
|
||||
color_background = None
|
||||
if bool(cfg.get("use_background_map", False)):
|
||||
print("[vision] снимаю фоновую карту сцены — лента должна быть ПУСТОЙ…")
|
||||
try:
|
||||
background = cam.capture_background(samples=15)
|
||||
print("[vision] фоновая карта активна (сегментация относительно фона)")
|
||||
except RuntimeError as exc:
|
||||
print(f"[vision] фоновая карта не снята ({exc}), работаю по скалярной высоте")
|
||||
color_background = cam.capture_background_rgb(samples=10)
|
||||
if color_background is not None:
|
||||
print("[vision] RGB-фон снят — плоские товары (телефон) будут детектироваться")
|
||||
|
||||
bridge = MqttBridge(mqtt_cfg)
|
||||
print(f"[vision] MQTT: {'OK' if bridge.connected else 'offline/disabled'}")
|
||||
if bridge.connected:
|
||||
bridge.start_conveyor()
|
||||
if show_preview:
|
||||
print(f"[vision] превью → {live_color} и {live_depth} (обновляются на лету)")
|
||||
print("[vision] откройте файлы в IDE/файловом менеджере или: eog debug_frames/live_color.jpg")
|
||||
|
||||
confirm_need = int(rt.get("confirm_frames", 8))
|
||||
process_every_n = max(1, int(rt.get("process_every_n", 1)))
|
||||
frame_i = 0
|
||||
thr = float(cls_cfg.get("circle_ratio_threshold", 0.8))
|
||||
fallback_zone = str(cls_cfg.get("uncertain_fallback_zone", "C")).upper()
|
||||
stabilizer = DecisionStabilizer(
|
||||
window=12,
|
||||
confirm_frames=confirm_need,
|
||||
lost_frames=12,
|
||||
enter_circle=thr,
|
||||
exit_circle=thr - 0.08,
|
||||
uncertain_after=int(cls_cfg.get("uncertain_after_frames", 45)),
|
||||
fallback=ZONE_TO_CATEGORY.get(fallback_zone, Category.OVERSIZE),
|
||||
)
|
||||
last_routed_zone: Optional[str] = None
|
||||
decisions_log = Path(rt.get("decisions_log", "logs/decisions.jsonl"))
|
||||
|
||||
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
|
||||
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
|
||||
|
||||
try:
|
||||
while True:
|
||||
pair = cam.read()
|
||||
if pair is None:
|
||||
print("[vision] нет кадра", file=sys.stderr)
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
frame_i += 1
|
||||
measurement = None
|
||||
result = None
|
||||
|
||||
if frame_i % process_every_n == 0:
|
||||
candidates = segment_objects(
|
||||
pair.depth_mm,
|
||||
belt_distance_mm=belt_mm,
|
||||
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
|
||||
min_object_height_mm=float(cfg.get("min_object_height_mm", 5)),
|
||||
min_area_px=int(cfg.get("min_object_area_px", 800)),
|
||||
background_mm=background,
|
||||
max_objects=int(cfg.get("max_objects_in_frame", 3)),
|
||||
roi_margin=cfg.get("roi_margin"),
|
||||
)
|
||||
seg = None
|
||||
best = None # (score, mask, contour, measurement)
|
||||
for mask, contour in candidates:
|
||||
m_try = measure_object(
|
||||
pair.depth_mm,
|
||||
mask,
|
||||
contour,
|
||||
belt_distance_mm=belt_mm,
|
||||
fx=fx,
|
||||
fy=fy,
|
||||
cx=cx,
|
||||
cy=cy,
|
||||
background_mm=background,
|
||||
min_object_height_mm=float(cfg.get("min_object_height_mm", 8)),
|
||||
roi_margin=cfg.get("roi_margin"),
|
||||
)
|
||||
if m_try is None or not is_plausible_measurement(m_try):
|
||||
continue
|
||||
# приоритет: круглый и более высокий товар над шумом ленты
|
||||
score = float(m_try.circle_ratio) * 2.0 + min(float(m_try.height_mm), 200.0) / 100.0
|
||||
if best is None or score > best[0]:
|
||||
best = (score, mask, contour, m_try)
|
||||
if best is not None:
|
||||
_, mask, contour, measurement = best
|
||||
seg = (mask, contour)
|
||||
else:
|
||||
measurement = None
|
||||
|
||||
# depth ничего не видит → плоский товар (телефон) ищем по RGB
|
||||
if (
|
||||
measurement is None
|
||||
and bool(cfg.get("detect_flat_rgb", False))
|
||||
and color_background is not None
|
||||
and not pair.color_is_depth_preview
|
||||
):
|
||||
rgb_objs = segment_rgb_objects(
|
||||
pair.color_bgr,
|
||||
color_background,
|
||||
min_area_px=int(cfg.get("min_object_area_px", 800)),
|
||||
diff_threshold=int(cfg.get("rgb_diff_threshold", 35)),
|
||||
max_objects=1,
|
||||
exclude_mask=seg[0] if seg is not None else None,
|
||||
)
|
||||
if rgb_objs:
|
||||
mask, contour = rgb_objs[0]
|
||||
measurement = measure_flat_object(
|
||||
pair.depth_mm,
|
||||
mask,
|
||||
contour,
|
||||
belt_distance_mm=belt_mm,
|
||||
fx=fx,
|
||||
fy=fy,
|
||||
cx=cx,
|
||||
cy=cy,
|
||||
background_mm=background,
|
||||
color_bgr=pair.color_bgr,
|
||||
color_bg_bgr=color_background,
|
||||
)
|
||||
|
||||
if measurement is not None and not is_plausible_measurement(measurement):
|
||||
measurement = None
|
||||
|
||||
decision = stabilizer.update(
|
||||
measurement,
|
||||
min_mm=cls_cfg.get("min_mm", [10, 10, 10]),
|
||||
max_mm=cls_cfg.get("max_mm", [450, 320, 320]),
|
||||
)
|
||||
if decision.locked and decision.result is not None:
|
||||
result = decision.result
|
||||
zone = result.category.zone
|
||||
if zone != last_routed_zone:
|
||||
tag = "UNCERTAIN→" if decision.uncertain else "LOCK "
|
||||
print(
|
||||
f"[vision] {tag}{zone} | {result.category.ru_label} | "
|
||||
f"dims={result.dims_sorted_mm} | ratio={result.circle_ratio:.3f} | {result.reason}"
|
||||
)
|
||||
append_decision(decisions_log, result, uncertain=decision.uncertain, source="main")
|
||||
bridge.publish_result(result)
|
||||
bridge.route(result.category)
|
||||
last_routed_zone = zone
|
||||
elif not decision.present:
|
||||
last_routed_zone = None
|
||||
|
||||
if show_preview or save_debug:
|
||||
vis, depth_vis = draw_overlay(pair.color_bgr, pair.depth_mm, measurement, result, belt_mm)
|
||||
if save_debug and result is not None and not show_preview:
|
||||
out = debug_dir / f"frame_{frame_i:06d}_{result.category.value}.jpg"
|
||||
cv2.imwrite(str(out), vis)
|
||||
# headless OpenCV: пишем JPEG вместо cv2.imshow
|
||||
if show_preview and frame_i % preview_every == 0:
|
||||
cv2.imwrite(str(live_color), vis)
|
||||
cv2.imwrite(str(live_depth), depth_vis)
|
||||
|
||||
if args.once:
|
||||
if result is not None:
|
||||
print(result)
|
||||
if show_preview:
|
||||
vis, depth_vis = draw_overlay(pair.color_bgr, pair.depth_mm, measurement, result, belt_mm)
|
||||
cv2.imwrite(str(live_color), vis)
|
||||
cv2.imwrite(str(live_depth), depth_vis)
|
||||
print(f"[vision] кадр сохранён: {live_color}")
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n[vision] stop")
|
||||
finally:
|
||||
bridge.close()
|
||||
cam.release()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Чтобы импорты работали и как пакет, и как скрипт
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
raise SystemExit(main())
|
||||
719
vision_classifier/measure.py
Normal file
719
vision_classifier/measure.py
Normal file
@@ -0,0 +1,719 @@
|
||||
"""Сегментация и измерение по ТЗ трека 3 — без эвристик «подкрутки»."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObjectMeasurement:
|
||||
length_mm: float
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
circle_ratio: float # итог для классификатора: max устойчивых сечений
|
||||
area_px: int
|
||||
centroid_px: Tuple[int, int]
|
||||
contour: np.ndarray
|
||||
mask: np.ndarray
|
||||
top_ratio: float = 0.0 # rin/rout вида сверху
|
||||
section_ratio: float = 0.0 # лучший устойчивый 3D-срез
|
||||
source: str = "depth" # "depth" | "rgb" (плоский товар, найден по RGB)
|
||||
clipped_by_frame: bool = False # объект упирается в край кадра → габарит неполный
|
||||
|
||||
|
||||
def is_plausible_measurement(
|
||||
m: ObjectMeasurement,
|
||||
min_footprint_mm: float = 15.0,
|
||||
) -> bool:
|
||||
"""Отсев шума depth/RGB до классификации (не путать с ТЗ-негабаритом).
|
||||
|
||||
Мелкие пятна и «0×0×0 мм» не должны попадать в трекер — иначе
|
||||
check_size(<10 мм) даёт ложный класс C.
|
||||
"""
|
||||
L, W, H = float(m.length_mm), float(m.width_mm), float(m.height_mm)
|
||||
if L <= 1.5 or W <= 1.5:
|
||||
return False
|
||||
if int(m.area_px) < 120:
|
||||
return False
|
||||
a, b, c = sorted((L, W, H), reverse=True)
|
||||
if a < min_footprint_mm:
|
||||
return False
|
||||
if c <= 0.5:
|
||||
return False
|
||||
# RGB-шум: нет высоты и крошечное пятно на ленте
|
||||
if getattr(m, "source", "depth") == "rgb" and H < 2.0 and a < 45.0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def mask_iou(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Классический IoU масок."""
|
||||
inter = int(np.count_nonzero((a > 0) & (b > 0)))
|
||||
if inter == 0:
|
||||
return 0.0
|
||||
ua = int(np.count_nonzero(a > 0))
|
||||
ub = int(np.count_nonzero(b > 0))
|
||||
return inter / float(ua + ub - inter)
|
||||
|
||||
|
||||
def mask_overlap_min(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Доля пересечения относительно меньшей маски (0..1).
|
||||
|
||||
≥0.5 ≈ «хотя бы половина одного объекта лежит на другом».
|
||||
Удобнее IoU, когда кусок намного меньше целого.
|
||||
"""
|
||||
inter = int(np.count_nonzero((a > 0) & (b > 0)))
|
||||
if inter == 0:
|
||||
return 0.0
|
||||
ua = int(np.count_nonzero(a > 0))
|
||||
ub = int(np.count_nonzero(b > 0))
|
||||
return inter / float(max(1, min(ua, ub)))
|
||||
|
||||
|
||||
def merge_overlapping_masks(
|
||||
items: List[Tuple[np.ndarray, np.ndarray]],
|
||||
overlap_thr: float = 0.5,
|
||||
near_gap_px: int = 14,
|
||||
) -> List[Tuple[np.ndarray, np.ndarray]]:
|
||||
"""Склеить контуры при IoU/пересечении ≥ thr или узкой дыре depth (near_gap)."""
|
||||
if len(items) <= 1:
|
||||
return items
|
||||
items = sorted(items, key=lambda ic: cv2.contourArea(ic[1]), reverse=True)
|
||||
used = [False] * len(items)
|
||||
out: List[Tuple[np.ndarray, np.ndarray]] = []
|
||||
|
||||
def _near(a: np.ndarray, b: np.ndarray) -> bool:
|
||||
xa, ya, wa, ha = cv2.boundingRect(a)
|
||||
xb, yb, wb, hb = cv2.boundingRect(b)
|
||||
g = int(near_gap_px)
|
||||
return not (
|
||||
xa + wa + g < xb or xb + wb + g < xa or ya + ha + g < yb or yb + hb + g < ya
|
||||
)
|
||||
|
||||
for i, (mask_i, _) in enumerate(items):
|
||||
if used[i]:
|
||||
continue
|
||||
merged = mask_i.copy()
|
||||
used[i] = True
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for j, (mask_j, _) in enumerate(items):
|
||||
if used[j]:
|
||||
continue
|
||||
hit = (
|
||||
mask_overlap_min(merged, mask_j) >= overlap_thr
|
||||
or mask_iou(merged, mask_j) >= overlap_thr
|
||||
or _near(merged, mask_j)
|
||||
)
|
||||
if hit:
|
||||
merged = cv2.bitwise_or(merged, mask_j)
|
||||
used[j] = True
|
||||
changed = True
|
||||
k = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
|
||||
merged = cv2.morphologyEx(merged, cv2.MORPH_CLOSE, k, iterations=1)
|
||||
contours, _ = cv2.findContours(merged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
continue
|
||||
contour = max(contours, key=cv2.contourArea)
|
||||
clean = np.zeros_like(merged)
|
||||
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
|
||||
out.append((clean, contour))
|
||||
return out
|
||||
|
||||
|
||||
def merge_overlapping_measurements(
|
||||
measurements: List[ObjectMeasurement],
|
||||
overlap_thr: float = 0.5,
|
||||
) -> List[ObjectMeasurement]:
|
||||
"""Склеить измерения с пересекающимися масками (оставить более крупное)."""
|
||||
if len(measurements) <= 1:
|
||||
return measurements
|
||||
ms = sorted(measurements, key=lambda m: m.area_px, reverse=True)
|
||||
kept: List[ObjectMeasurement] = []
|
||||
for m in ms:
|
||||
drop = False
|
||||
for k in kept:
|
||||
if m.mask.shape != k.mask.shape:
|
||||
continue
|
||||
if mask_overlap_min(m.mask, k.mask) >= overlap_thr or mask_iou(m.mask, k.mask) >= overlap_thr:
|
||||
drop = True
|
||||
break
|
||||
if not drop:
|
||||
kept.append(m)
|
||||
return kept
|
||||
|
||||
|
||||
def apply_roi_margin(
|
||||
mask: np.ndarray,
|
||||
margin: Optional[dict] = None,
|
||||
) -> np.ndarray:
|
||||
"""Обнулить края кадра (ролики, борта, плата), доли 0..1 от H/W."""
|
||||
if not margin:
|
||||
return mask
|
||||
h, w = mask.shape[:2]
|
||||
top = int(h * float(margin.get("top", 0)))
|
||||
bottom = int(h * float(margin.get("bottom", 0)))
|
||||
left = int(w * float(margin.get("left", 0)))
|
||||
right = int(w * float(margin.get("right", 0)))
|
||||
out = mask.copy()
|
||||
if top > 0:
|
||||
out[:top, :] = 0
|
||||
if bottom > 0:
|
||||
out[h - bottom :, :] = 0
|
||||
if left > 0:
|
||||
out[:, :left] = 0
|
||||
if right > 0:
|
||||
out[:, w - right :] = 0
|
||||
return out
|
||||
|
||||
|
||||
def contour_touches_border(
|
||||
contour: np.ndarray,
|
||||
shape: Tuple[int, ...],
|
||||
margin_px: int = 3,
|
||||
roi_margin: Optional[dict] = None,
|
||||
) -> bool:
|
||||
"""True если контур упирается в край кадра/ROI — реальный размер может быть больше."""
|
||||
h, w = int(shape[0]), int(shape[1])
|
||||
top = int(h * float((roi_margin or {}).get("top", 0)))
|
||||
bottom = int(h * float((roi_margin or {}).get("bottom", 0)))
|
||||
left = int(w * float((roi_margin or {}).get("left", 0)))
|
||||
right = int(w * float((roi_margin or {}).get("right", 0)))
|
||||
y0, y1 = top + margin_px, h - bottom - 1 - margin_px
|
||||
x0, x1 = left + margin_px, w - right - 1 - margin_px
|
||||
pts = contour.reshape(-1, 2)
|
||||
xs, ys = pts[:, 0], pts[:, 1]
|
||||
return bool(
|
||||
(xs <= x0).any()
|
||||
or (xs >= x1).any()
|
||||
or (ys <= y0).any()
|
||||
or (ys >= y1).any()
|
||||
)
|
||||
|
||||
|
||||
def segment_objects(
|
||||
depth_mm: np.ndarray,
|
||||
belt_distance_mm: float,
|
||||
belt_tolerance_mm: float = 25.0,
|
||||
min_object_height_mm: float = 5.0,
|
||||
min_area_px: int = 800,
|
||||
background_mm: Optional[np.ndarray] = None,
|
||||
max_objects: int = 3,
|
||||
roi_margin: Optional[dict] = None,
|
||||
) -> List[Tuple[np.ndarray, np.ndarray]]:
|
||||
"""Все объекты в кадре (крупнейшие первыми), до max_objects штук.
|
||||
|
||||
Порог высоты — как раньше (строгий): мягкий «ореол» раздувал маску на ленту
|
||||
и ломал габариты/круг → путаница B/C/D.
|
||||
"""
|
||||
valid = (depth_mm > 50) & (depth_mm < 5000)
|
||||
hmin = float(min_object_height_mm)
|
||||
if background_mm is not None:
|
||||
bg = background_mm.astype(np.float32)
|
||||
d = depth_mm.astype(np.float32)
|
||||
raised = valid & (bg > 50) & (d < bg - hmin)
|
||||
near_belt_band = d > (bg - 520.0)
|
||||
else:
|
||||
raised = valid & (depth_mm < (belt_distance_mm - hmin))
|
||||
near_belt_band = depth_mm > (belt_distance_mm - 450)
|
||||
mask = (raised & near_belt_band).astype(np.uint8) * 255
|
||||
mask = apply_roi_margin(mask, roi_margin)
|
||||
|
||||
k_open = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
||||
k_close = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open, iterations=1)
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close, iterations=2)
|
||||
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
frame_area = mask.shape[0] * mask.shape[1]
|
||||
raw: List[Tuple[np.ndarray, np.ndarray]] = []
|
||||
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
|
||||
area = cv2.contourArea(contour)
|
||||
# крупные товары могут занимать почти весь кадр — не отсекать как шум
|
||||
if area < min_area_px or area > frame_area * 0.92:
|
||||
continue
|
||||
clean = np.zeros_like(mask)
|
||||
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
|
||||
raw.append((clean, contour))
|
||||
|
||||
# только явное пересечение / узкая щель — не склеивать соседние товары
|
||||
merged = merge_overlapping_masks(raw, overlap_thr=0.5, near_gap_px=14)
|
||||
return merged[:max_objects]
|
||||
|
||||
|
||||
def segment_object(
|
||||
depth_mm: np.ndarray,
|
||||
belt_distance_mm: float,
|
||||
belt_tolerance_mm: float = 25.0,
|
||||
min_object_height_mm: float = 5.0,
|
||||
min_area_px: int = 800,
|
||||
background_mm: Optional[np.ndarray] = None,
|
||||
roi_margin: Optional[dict] = None,
|
||||
) -> Optional[Tuple[np.ndarray, np.ndarray]]:
|
||||
"""Крупнейший объект (для main.py/calibrate.py — один товар в накопителе)."""
|
||||
objs = segment_objects(
|
||||
depth_mm,
|
||||
belt_distance_mm,
|
||||
belt_tolerance_mm=belt_tolerance_mm,
|
||||
min_object_height_mm=min_object_height_mm,
|
||||
min_area_px=min_area_px,
|
||||
background_mm=background_mm,
|
||||
max_objects=1,
|
||||
roi_margin=roi_margin,
|
||||
)
|
||||
return objs[0] if objs else None
|
||||
|
||||
|
||||
def _pixel_to_xy_mm(
|
||||
u: float, v: float, z_mm: float, fx: float, fy: float, cx: float, cy: float
|
||||
) -> Tuple[float, float]:
|
||||
return (u - cx) * z_mm / fx, (v - cy) * z_mm / fy
|
||||
|
||||
|
||||
def measure_object(
|
||||
depth_mm: np.ndarray,
|
||||
mask: np.ndarray,
|
||||
contour: np.ndarray,
|
||||
belt_distance_mm: float,
|
||||
fx: float,
|
||||
fy: float,
|
||||
cx: float,
|
||||
cy: float,
|
||||
background_mm: Optional[np.ndarray] = None,
|
||||
min_object_height_mm: float = 5.0,
|
||||
roi_margin: Optional[dict] = None,
|
||||
) -> Optional[ObjectMeasurement]:
|
||||
ys, xs = np.where(mask > 0)
|
||||
if xs.size < 50:
|
||||
return None
|
||||
|
||||
z_vals = depth_mm[ys, xs].astype(np.float32)
|
||||
z_vals = z_vals[z_vals > 0]
|
||||
if z_vals.size < 50:
|
||||
return None
|
||||
|
||||
z_med = float(np.median(z_vals))
|
||||
# высота относительно локального фона (платформа/лента под объектом)
|
||||
belt_local = belt_distance_mm
|
||||
if background_mm is not None:
|
||||
bg_vals = background_mm[ys, xs].astype(np.float32)
|
||||
bg_vals = bg_vals[bg_vals > 50]
|
||||
if bg_vals.size >= 50:
|
||||
belt_local = float(np.median(bg_vals))
|
||||
height_mm = max(0.0, belt_local - z_med)
|
||||
|
||||
pts_mm = []
|
||||
for p in contour.reshape(-1, 2):
|
||||
u, v = float(p[0]), float(p[1])
|
||||
x, y = _pixel_to_xy_mm(u, v, z_med, fx, fy, cx, cy)
|
||||
pts_mm.append([x, y])
|
||||
pts_mm = np.asarray(pts_mm, dtype=np.float32)
|
||||
if pts_mm.shape[0] < 5:
|
||||
return None
|
||||
|
||||
rect = cv2.minAreaRect(pts_mm.reshape(-1, 1, 2))
|
||||
rw, rh = rect[1]
|
||||
length_mm = float(max(rw, rh))
|
||||
width_mm = float(min(rw, rh))
|
||||
|
||||
touches_edge = contour_touches_border(contour, mask.shape, roi_margin=roi_margin)
|
||||
# Негабарит «не влезает в кадр» только если реально занимает большую долю FOV.
|
||||
# Лежачая бутылка может чуть касаться ROI — это не повод форсировать 500 мм.
|
||||
span_x = float(xs.max() - xs.min())
|
||||
span_y = float(ys.max() - ys.min())
|
||||
mh = float((roi_margin or {}).get("top", 0.0)) + float((roi_margin or {}).get("bottom", 0.0))
|
||||
mw = float((roi_margin or {}).get("left", 0.0)) + float((roi_margin or {}).get("right", 0.0))
|
||||
usable_w = mask.shape[1] * max(0.5, 1.0 - mw)
|
||||
usable_h = mask.shape[0] * max(0.5, 1.0 - mh)
|
||||
spans_frame = (span_x >= 0.72 * usable_w) or (span_y >= 0.72 * usable_h)
|
||||
clipped = bool(touches_edge and spans_frame)
|
||||
|
||||
# отсев шума ленты / руки на краю (низкий «холм» большой площади → не товар)
|
||||
# но не отсекаем крупные обрезанные объекты — они уйдут в C
|
||||
if not clipped and height_mm < 30.0 and max(length_mm, width_mm) > 150.0:
|
||||
return None
|
||||
# жёсткий пол — иначе складки ленты дают ложный C
|
||||
if height_mm < max(20.0, float(min_object_height_mm)):
|
||||
return None
|
||||
# раньше >520 отбрасывали → ложный B на негабарите в FOV;
|
||||
# оставляем измерение: classify отправит в C (>450)
|
||||
if max(length_mm, width_mm) > 2000.0 and not clipped:
|
||||
return None
|
||||
|
||||
# объект не помещается в кадр → габарит занижен; форсируем > max ТЗ
|
||||
if clipped:
|
||||
length_mm = max(length_mm, 500.0)
|
||||
|
||||
top_ratio = rin_rout(pts_mm)
|
||||
section_ratio = 0.0
|
||||
cloud = _point_cloud(depth_mm, mask, fx, fy, cx, cy)
|
||||
if cloud is not None:
|
||||
section_ratio = robust_section_ratio(cloud)
|
||||
|
||||
# ТЗ: круг в любом сечении. Один шумный 3D-срез не считаем:
|
||||
# D только если top>=0.8 ИЛИ ≥2 среза >=0.8 (внутри robust_section_ratio).
|
||||
circle_ratio = max(top_ratio, section_ratio)
|
||||
if clipped:
|
||||
# обрезанный негабарит не классифицируем по кругу
|
||||
circle_ratio = min(circle_ratio, 0.5)
|
||||
|
||||
m = cv2.moments(contour)
|
||||
if m["m00"] > 0:
|
||||
cx_px = int(m["m10"] / m["m00"])
|
||||
cy_px = int(m["m01"] / m["m00"])
|
||||
else:
|
||||
cx_px, cy_px = int(xs.mean()), int(ys.mean())
|
||||
|
||||
return ObjectMeasurement(
|
||||
length_mm=length_mm,
|
||||
width_mm=width_mm,
|
||||
height_mm=height_mm,
|
||||
circle_ratio=float(circle_ratio),
|
||||
area_px=int(xs.size),
|
||||
centroid_px=(cx_px, cy_px),
|
||||
contour=contour,
|
||||
mask=mask,
|
||||
top_ratio=float(top_ratio),
|
||||
section_ratio=float(section_ratio),
|
||||
clipped_by_frame=bool(clipped),
|
||||
)
|
||||
|
||||
|
||||
def segment_rgb_objects(
|
||||
color_bgr: np.ndarray,
|
||||
color_bg_bgr: np.ndarray,
|
||||
min_area_px: int = 800,
|
||||
diff_threshold: int = 35,
|
||||
max_objects: int = 3,
|
||||
exclude_mask: Optional[np.ndarray] = None,
|
||||
) -> List[Tuple[np.ndarray, np.ndarray]]:
|
||||
"""Плоские товары (телефон и т.п.) по разнице с RGB-фоном пустой ленты.
|
||||
|
||||
exclude_mask — зоны, уже найденные по depth (не дублируем объекты).
|
||||
Тени (пропорциональное затемнение каналов) отбрасываются.
|
||||
"""
|
||||
if color_bgr.shape != color_bg_bgr.shape:
|
||||
return []
|
||||
fg = color_bgr.astype(np.float32)
|
||||
bg = color_bg_bgr.astype(np.float32)
|
||||
gray = np.max(np.abs(fg - bg), axis=2).astype(np.uint8)
|
||||
_, m = cv2.threshold(gray, int(diff_threshold), 255, cv2.THRESH_BINARY)
|
||||
|
||||
ratio = (fg + 8.0) / (bg + 8.0)
|
||||
r_med = np.median(ratio, axis=2)
|
||||
r_spread = np.max(ratio, axis=2) - np.min(ratio, axis=2)
|
||||
is_shadow = (r_med < 0.93) & (r_med > 0.38) & (r_spread < 0.14)
|
||||
m[is_shadow] = 0
|
||||
|
||||
k = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
|
||||
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k, iterations=1)
|
||||
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k, iterations=2)
|
||||
|
||||
if exclude_mask is not None:
|
||||
excl = cv2.dilate(exclude_mask, cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)))
|
||||
m[excl > 0] = 0
|
||||
|
||||
contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
out: List[Tuple[np.ndarray, np.ndarray]] = []
|
||||
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
|
||||
if cv2.contourArea(contour) < min_area_px or len(out) >= max_objects:
|
||||
break
|
||||
clean = np.zeros_like(m)
|
||||
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
|
||||
out.append((clean, contour))
|
||||
return out
|
||||
|
||||
|
||||
def measure_flat_object(
|
||||
depth_mm: np.ndarray,
|
||||
mask: np.ndarray,
|
||||
contour: np.ndarray,
|
||||
belt_distance_mm: float,
|
||||
fx: float,
|
||||
fy: float,
|
||||
cx: float,
|
||||
cy: float,
|
||||
background_mm: Optional[np.ndarray] = None,
|
||||
color_bgr: Optional[np.ndarray] = None,
|
||||
color_bg_bgr: Optional[np.ndarray] = None,
|
||||
) -> Optional[ObjectMeasurement]:
|
||||
"""Измерение товара, найденного по RGB: без отсева по минимальной высоте."""
|
||||
ys, xs = np.where(mask > 0)
|
||||
if xs.size < 50:
|
||||
return None
|
||||
|
||||
z_plane = belt_distance_mm
|
||||
if background_mm is not None:
|
||||
bg_vals = background_mm[ys, xs].astype(np.float32)
|
||||
bg_vals = bg_vals[bg_vals > 50]
|
||||
if bg_vals.size >= 50:
|
||||
z_plane = float(np.median(bg_vals))
|
||||
|
||||
z_vals = depth_mm[ys, xs].astype(np.float32)
|
||||
z_vals = z_vals[z_vals > 0]
|
||||
height_mm = max(0.0, z_plane - float(np.median(z_vals))) if z_vals.size >= 50 else 0.0
|
||||
|
||||
pts_mm = []
|
||||
for p in contour.reshape(-1, 2):
|
||||
x, y = _pixel_to_xy_mm(float(p[0]), float(p[1]), z_plane, fx, fy, cx, cy)
|
||||
pts_mm.append([x, y])
|
||||
pts_mm = np.asarray(pts_mm, dtype=np.float32)
|
||||
if pts_mm.shape[0] < 3:
|
||||
return None
|
||||
|
||||
rect = cv2.minAreaRect(pts_mm.reshape(-1, 1, 2))
|
||||
rw, rh = rect[1]
|
||||
length_mm = float(max(rw, rh))
|
||||
width_mm = float(min(rw, rh))
|
||||
if max(length_mm, width_mm) > 520.0 or max(length_mm, width_mm) < 5.0:
|
||||
return None
|
||||
|
||||
top_ratio = rin_rout(pts_mm)
|
||||
|
||||
m = cv2.moments(contour)
|
||||
if m["m00"] > 0:
|
||||
cx_px, cy_px = int(m["m10"] / m["m00"]), int(m["m01"] / m["m00"])
|
||||
else:
|
||||
cx_px, cy_px = int(xs.mean()), int(ys.mean())
|
||||
|
||||
return ObjectMeasurement(
|
||||
length_mm=length_mm,
|
||||
width_mm=width_mm,
|
||||
height_mm=float(height_mm),
|
||||
circle_ratio=float(top_ratio),
|
||||
area_px=int(xs.size),
|
||||
centroid_px=(cx_px, cy_px),
|
||||
contour=contour,
|
||||
mask=mask,
|
||||
top_ratio=float(top_ratio),
|
||||
section_ratio=0.0,
|
||||
source="rgb",
|
||||
)
|
||||
|
||||
|
||||
def rin_rout(pts_xy: np.ndarray) -> float:
|
||||
"""ТЗ: r_in / r_out по выпуклой оболочке сечения."""
|
||||
pts = np.asarray(pts_xy, dtype=np.float32).reshape(-1, 2)
|
||||
if pts.shape[0] < 3:
|
||||
return 0.0
|
||||
|
||||
hull = cv2.convexHull(pts.reshape(-1, 1, 2))
|
||||
hull_pts = hull.reshape(-1, 2)
|
||||
if hull_pts.shape[0] < 3:
|
||||
return 0.0
|
||||
|
||||
(_center, r_out) = cv2.minEnclosingCircle(hull)
|
||||
r_out = float(r_out)
|
||||
if r_out < 1e-6:
|
||||
return 0.0
|
||||
|
||||
r_in = _inscribed_radius_mm(hull_pts)
|
||||
if r_in <= 0:
|
||||
return 0.0
|
||||
return float(np.clip(r_in / r_out, 0.0, 1.0))
|
||||
|
||||
|
||||
def robust_section_ratio(cloud: np.ndarray, thr: float = 0.8) -> float:
|
||||
"""
|
||||
Поперечные срезы. Из логов: у круга часто 1–2 среза ≥0.85, иногда медиана падает.
|
||||
- ≥1 срез с score≥0.85 → принимаем (уверенный круг/дуга);
|
||||
- иначе ≥2 среза ≥0.8 → max;
|
||||
- иначе медиана (антишум для коробки).
|
||||
"""
|
||||
ratios = _section_ratios_3d(cloud)
|
||||
if not ratios:
|
||||
return 0.0
|
||||
very = [r for r in ratios if r >= 0.85]
|
||||
if very:
|
||||
return float(max(very))
|
||||
strong = [r for r in ratios if r >= thr]
|
||||
if len(strong) >= 2:
|
||||
return float(max(strong))
|
||||
return float(np.median(ratios))
|
||||
|
||||
|
||||
def _point_cloud(
|
||||
depth_mm: np.ndarray,
|
||||
mask: np.ndarray,
|
||||
fx: float,
|
||||
fy: float,
|
||||
cx: float,
|
||||
cy: float,
|
||||
) -> Optional[np.ndarray]:
|
||||
ys, xs = np.where(mask > 0)
|
||||
if xs.size < 150:
|
||||
return None
|
||||
z = depth_mm[ys, xs].astype(np.float32)
|
||||
ok = (z > 50) & (z < 5000)
|
||||
xs, ys, z = xs[ok], ys[ok], z[ok]
|
||||
if xs.size < 150:
|
||||
return None
|
||||
# детерминированный даунсэмпл (без random)
|
||||
if xs.size > 4000:
|
||||
step = int(np.ceil(xs.size / 4000))
|
||||
xs, ys, z = xs[::step], ys[::step], z[::step]
|
||||
X = (xs.astype(np.float32) - cx) * z / fx
|
||||
Y = (ys.astype(np.float32) - cy) * z / fy
|
||||
return np.column_stack([X, Y, z]).astype(np.float32)
|
||||
|
||||
|
||||
def _section_ratios_3d(cloud: np.ndarray) -> List[float]:
|
||||
mean = cloud.mean(axis=0)
|
||||
centered = cloud - mean
|
||||
try:
|
||||
_u, s, vt = np.linalg.svd(centered, full_matrices=False)
|
||||
except np.linalg.LinAlgError:
|
||||
return []
|
||||
|
||||
out: List[float] = []
|
||||
# только вдоль самой длинной оси — поперечные сечения цилиндра/коробки
|
||||
for axis_i in range(min(1, vt.shape[0])):
|
||||
if float(s[axis_i]) < 1e-6:
|
||||
continue
|
||||
axis = vt[axis_i]
|
||||
axis = axis / (np.linalg.norm(axis) + 1e-9)
|
||||
along = centered @ axis
|
||||
|
||||
ref = np.array([0.0, 0.0, 1.0], dtype=np.float32)
|
||||
if abs(float(np.dot(axis, ref))) > 0.9:
|
||||
ref = np.array([1.0, 0.0, 0.0], dtype=np.float32)
|
||||
u = np.cross(axis, ref)
|
||||
u /= np.linalg.norm(u) + 1e-9
|
||||
v = np.cross(axis, u)
|
||||
|
||||
a0, a1 = float(np.percentile(along, 15)), float(np.percentile(along, 85))
|
||||
if a1 - a0 < 10.0:
|
||||
continue
|
||||
|
||||
for t in (0.2, 0.35, 0.5, 0.65, 0.8):
|
||||
ca = a0 + t * (a1 - a0)
|
||||
half = max(4.0, 0.06 * (a1 - a0))
|
||||
band = np.abs(along - ca) <= half
|
||||
if int(band.sum()) < 40:
|
||||
continue
|
||||
pts = centered[band]
|
||||
sec = np.column_stack([pts @ u, pts @ v]).astype(np.float32)
|
||||
out.append(_section_score(sec))
|
||||
return out
|
||||
|
||||
|
||||
def _section_score(sec: np.ndarray) -> float:
|
||||
"""
|
||||
Чистый rin/rout. Для дуги лежачего цилиндра (depth видит полкруга)
|
||||
допускаем score 0.85 только при жёстком circle-fit:
|
||||
малый residual, почти равные радиусы, покрытие ≥200°, bbox не «палка».
|
||||
Прямоугольное сечение fit не проходит → остаётся rin/rout < 0.8.
|
||||
"""
|
||||
direct = rin_rout(sec)
|
||||
if direct >= 0.8:
|
||||
return float(direct)
|
||||
|
||||
fit = _fit_circle_arc(sec)
|
||||
if fit is None:
|
||||
return float(direct)
|
||||
return float(max(direct, 0.85))
|
||||
|
||||
|
||||
def _fit_circle_arc(pts: np.ndarray) -> Optional[Tuple[np.ndarray, float]]:
|
||||
pts = np.asarray(pts, dtype=np.float64).reshape(-1, 2)
|
||||
if pts.shape[0] < 35:
|
||||
return None
|
||||
|
||||
c0 = pts.mean(axis=0)
|
||||
x0 = pts - c0
|
||||
try:
|
||||
_u, s, _vt = np.linalg.svd(x0, full_matrices=False)
|
||||
except np.linalg.LinAlgError:
|
||||
return None
|
||||
if s.shape[0] < 2 or float(s[0]) < 1e-6:
|
||||
return None
|
||||
# сечение не должно быть линией
|
||||
if float(s[1] / (s[0] + 1e-9)) < 0.45:
|
||||
return None
|
||||
|
||||
x, y = pts[:, 0], pts[:, 1]
|
||||
A = np.column_stack([2 * x, 2 * y, np.ones_like(x)])
|
||||
b = x * x + y * y
|
||||
try:
|
||||
sol, *_ = np.linalg.lstsq(A, b, rcond=None)
|
||||
except np.linalg.LinAlgError:
|
||||
return None
|
||||
cx_, cy_, c = sol
|
||||
r2 = c + cx_ * cx_ + cy_ * cy_
|
||||
if r2 <= 1.0:
|
||||
return None
|
||||
r = float(np.sqrt(r2))
|
||||
rad = np.sqrt((x - cx_) ** 2 + (y - cy_) ** 2)
|
||||
rel = float(np.sqrt(np.mean((rad - r) ** 2)) / (r + 1e-9))
|
||||
rad_cv = float(rad.std() / (rad.mean() + 1e-9))
|
||||
if rel > 0.04 or rad_cv > 0.04:
|
||||
return None
|
||||
|
||||
bw = float(x.max() - x.min())
|
||||
bh = float(y.max() - y.min())
|
||||
aspect = max(bw, bh) / max(min(bw, bh), 1e-6)
|
||||
# у круга/полукруга bbox близок к квадрату; у прямоугольника 2:1 — нет
|
||||
if aspect > 1.45:
|
||||
return None
|
||||
if r < 0.40 * max(bw, bh) or r > 0.70 * max(bw, bh):
|
||||
return None
|
||||
|
||||
ang = np.arctan2(y - cy_, x - cx_)
|
||||
ang = np.sort(ang)
|
||||
gaps = np.diff(ang)
|
||||
gaps = np.append(gaps, ang[0] + 2 * np.pi - ang[-1])
|
||||
coverage = float(2 * np.pi - gaps.max())
|
||||
if coverage < np.deg2rad(200.0):
|
||||
return None
|
||||
|
||||
return np.array([cx_, cy_], dtype=np.float32), r
|
||||
|
||||
|
||||
def _inscribed_radius_mm(pts_xy_mm: np.ndarray, grid: int = 192) -> float:
|
||||
x_min, y_min = pts_xy_mm.min(axis=0)
|
||||
x_max, y_max = pts_xy_mm.max(axis=0)
|
||||
span = max(float(x_max - x_min), float(y_max - y_min), 1.0)
|
||||
pad = 8
|
||||
inner = grid - 2 * pad
|
||||
if inner < 16:
|
||||
return 0.0
|
||||
scale = inner / span
|
||||
|
||||
img = np.zeros((grid, grid), dtype=np.uint8)
|
||||
pts_px = ((pts_xy_mm - np.array([x_min, y_min], dtype=np.float32)) * scale).astype(np.int32)
|
||||
pts_px[:, 0] = np.clip(pts_px[:, 0] + pad, 0, grid - 1)
|
||||
pts_px[:, 1] = np.clip(pts_px[:, 1] + pad, 0, grid - 1)
|
||||
cv2.fillPoly(img, [pts_px], 255)
|
||||
if img.max() == 0 or float((img > 0).mean()) > 0.98:
|
||||
return 0.0
|
||||
dist = cv2.distanceTransform(img, cv2.DIST_L2, 5)
|
||||
return float(dist.max()) / scale
|
||||
|
||||
|
||||
def circularity_ratio(pts_xy_mm: np.ndarray) -> float:
|
||||
return rin_rout(pts_xy_mm)
|
||||
|
||||
|
||||
# совместимость со старыми вызовами
|
||||
def max_section_circle_ratio(
|
||||
depth_mm: np.ndarray,
|
||||
mask: np.ndarray,
|
||||
top_pts_mm: np.ndarray,
|
||||
fx: float,
|
||||
fy: float,
|
||||
cx: float,
|
||||
cy: float,
|
||||
) -> float:
|
||||
top = rin_rout(top_pts_mm)
|
||||
cloud = _point_cloud(depth_mm, mask, fx, fy, cx, cy)
|
||||
sec = robust_section_ratio(cloud) if cloud is not None else 0.0
|
||||
return float(max(top, sec))
|
||||
|
||||
|
||||
def section_rin_rout(sec: np.ndarray) -> float:
|
||||
return rin_rout(sec)
|
||||
172
vision_classifier/mqtt_bridge.py
Normal file
172
vision_classifier/mqtt_bridge.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""MQTT: публикация категории + команды серво/мотору (существующий API Arduino)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from classify import Category, ClassificationResult
|
||||
|
||||
|
||||
class MqttBridge:
|
||||
def __init__(self, cfg: Dict[str, Any]) -> None:
|
||||
self.cfg = cfg
|
||||
self.enabled = bool(cfg.get("enabled", True))
|
||||
self.routing_cfg = cfg.get("_routing", {})
|
||||
self.motor_cfg = cfg.get("_motor", {})
|
||||
self._last_route_ts = 0.0
|
||||
self._last_category: Optional[str] = None
|
||||
|
||||
self.client = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=cfg.get("client_id", "vision_classifier_opi"),
|
||||
)
|
||||
user = cfg.get("user")
|
||||
password = cfg.get("password")
|
||||
if user:
|
||||
self.client.username_pw_set(user, password)
|
||||
|
||||
self._connected = False
|
||||
if self.enabled:
|
||||
try:
|
||||
self.client.connect(cfg["broker"], int(cfg.get("port", 1883)), 30)
|
||||
self.client.loop_start()
|
||||
# короткая проверка
|
||||
time.sleep(0.3)
|
||||
self._connected = True
|
||||
print(f"[mqtt] подключено к {cfg['broker']}:{cfg.get('port', 1883)}")
|
||||
except Exception as exc:
|
||||
print(f"[mqtt] не удалось подключиться: {exc}")
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def start_conveyor(self) -> None:
|
||||
"""Включить шаговик ленты через уже существующие топики motor/control/*."""
|
||||
m = self.motor_cfg
|
||||
if not m.get("enabled", False):
|
||||
return
|
||||
if not self.enabled or not self._connected:
|
||||
return
|
||||
rpm = int(m.get("rpm", 200))
|
||||
current = int(m.get("current_percent", 50))
|
||||
microsteps = int(m.get("microsteps", 16))
|
||||
self.client.publish("motor/control/driver", "on", qos=1)
|
||||
self.client.publish("motor/control/tmc/enable", "on", qos=1)
|
||||
self.client.publish("motor/control/tmc/current_percent", str(current), qos=1)
|
||||
self.client.publish("motor/control/tmc/microsteps", str(microsteps), qos=1)
|
||||
if m.get("stealthchop", True):
|
||||
self.client.publish("motor/control/tmc/stealthchop", "on", qos=1)
|
||||
self.client.publish("motor/control/rpm", str(rpm), qos=1)
|
||||
print(f"[mqtt] конвейер: driver ON, rpm={rpm}")
|
||||
|
||||
def stop_conveyor(self) -> None:
|
||||
m = self.motor_cfg
|
||||
if not m.get("enabled", False):
|
||||
return
|
||||
if not self.enabled or not self._connected:
|
||||
return
|
||||
self.client.publish("motor/control/rpm", "0", qos=1)
|
||||
if m.get("disable_on_stop", False):
|
||||
self.client.publish("motor/control/driver", "off", qos=1)
|
||||
print("[mqtt] конвейер: rpm=0")
|
||||
|
||||
def publish_result(self, result: ClassificationResult) -> None:
|
||||
if not self.enabled or not self._connected:
|
||||
return
|
||||
l, w, h = result.dims_sorted_mm
|
||||
self.client.publish(
|
||||
self.cfg.get("topic_result", "vision/feedback/category"),
|
||||
result.category.value,
|
||||
qos=1,
|
||||
)
|
||||
self.client.publish(
|
||||
self.cfg.get("topic_dims", "vision/feedback/dimensions"),
|
||||
f"{l:.1f},{w:.1f},{h:.1f}",
|
||||
qos=0,
|
||||
)
|
||||
self.client.publish(
|
||||
self.cfg.get("topic_circle", "vision/feedback/circle_ratio"),
|
||||
f"{result.circle_ratio:.4f}",
|
||||
qos=0,
|
||||
)
|
||||
payload = {
|
||||
"category": result.category.value,
|
||||
"zone": result.category.zone,
|
||||
"label_ru": result.category.ru_label,
|
||||
"dims_mm": [round(l, 1), round(w, 1), round(h, 1)],
|
||||
"circle_ratio": round(result.circle_ratio, 4),
|
||||
"reason": result.reason,
|
||||
}
|
||||
self.client.publish(
|
||||
self.cfg.get("topic_debug", "vision/feedback/debug"),
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
qos=0,
|
||||
)
|
||||
|
||||
def route(self, category: Category) -> None:
|
||||
"""Отправка команды серво через servo/control/{ch}/angle|enable."""
|
||||
routing = self.routing_cfg
|
||||
if not routing.get("enabled", True):
|
||||
return
|
||||
if not self.enabled or not self._connected:
|
||||
return
|
||||
|
||||
now = time.time() * 1000.0
|
||||
cooldown = float(routing.get("cooldown_ms", 1500))
|
||||
if category.value == self._last_category and (now - self._last_route_ts) < cooldown:
|
||||
return
|
||||
|
||||
zones = routing.get("zones", {})
|
||||
zone_key = category.zone
|
||||
zone = zones.get(zone_key)
|
||||
if not zone:
|
||||
return
|
||||
|
||||
for zk, zcfg in zones.items():
|
||||
ch = int(zcfg["servo"])
|
||||
idle = int(zcfg.get("idle_angle", 0))
|
||||
if zk == zone_key:
|
||||
continue
|
||||
self._set_servo(ch, idle, enable=True)
|
||||
|
||||
ch = int(zone["servo"])
|
||||
divert = int(zone.get("divert_angle", 90))
|
||||
idle = int(zone.get("idle_angle", 0))
|
||||
hold_ms = int(zone.get("hold_ms", 800))
|
||||
|
||||
if category == Category.SUITABLE and divert == idle:
|
||||
self._set_servo(ch, idle, enable=True)
|
||||
print(f"[mqtt] зона B — пропуск (servo {ch} idle)")
|
||||
else:
|
||||
self._set_servo(ch, divert, enable=True)
|
||||
print(f"[mqtt] зона {zone_key} — divert servo {ch} → {divert}°")
|
||||
|
||||
def _return_idle(channel: int = ch, angle: int = idle, delay_s: float = hold_ms / 1000.0) -> None:
|
||||
time.sleep(delay_s)
|
||||
self._set_servo(channel, angle, enable=True)
|
||||
|
||||
threading.Thread(target=_return_idle, daemon=True).start()
|
||||
|
||||
self._last_category = category.value
|
||||
self._last_route_ts = now
|
||||
|
||||
def _set_servo(self, channel: int, angle: int, enable: bool = True) -> None:
|
||||
base = f"servo/control/{channel}"
|
||||
self.client.publish(f"{base}/enable", "on" if enable else "off", qos=1)
|
||||
self.client.publish(f"{base}/angle", str(int(angle)), qos=1)
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self.stop_conveyor()
|
||||
except Exception:
|
||||
pass
|
||||
if self._connected:
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
6
vision_classifier/requirements.txt
Normal file
6
vision_classifier/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
opencv-python-headless>=4.8
|
||||
numpy>=1.24
|
||||
paho-mqtt>=2.0
|
||||
PyYAML>=6.0
|
||||
pillow>=10.0 # кириллица на HUD (demo_hud.py)
|
||||
# ffmpeg должен быть в системе (pacman/apt: ffmpeg) — depth Z16 читается через него
|
||||
12
vision_classifier/run.sh
Executable file
12
vision_classifier/run.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
if [[ ! -d .venv ]]; then
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -U pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
exec .venv/bin/python main.py "$@"
|
||||
306
vision_classifier/stabilize.py
Normal file
306
vision_classifier/stabilize.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Стабильный консенсус по кадрам.
|
||||
|
||||
Из логов RealSense: круг даёт sec≈0.82–0.87, но early LOCK на B
|
||||
залипал навсегда. Поэтому:
|
||||
• классификация по медиане окна;
|
||||
• LOCK после прогрева;
|
||||
• апгрейд B→D при устойчивом круге (≥ confirm кадров подряд);
|
||||
• D→B и смена зоны после LOCK запрещены (пока объект не исчез).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter, deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Deque, Optional, Sequence
|
||||
|
||||
from classify import Category, ClassificationResult, check_size
|
||||
from measure import ObjectMeasurement
|
||||
|
||||
|
||||
@dataclass
|
||||
class StableDecision:
|
||||
result: Optional[ClassificationResult]
|
||||
locked: bool
|
||||
confidence_pct: int
|
||||
ratio_smooth: float
|
||||
present: bool
|
||||
uncertain: bool = False # LOCK по правилу «нет консенсуса → безопасная зона»
|
||||
|
||||
|
||||
class DecisionStabilizer:
|
||||
def __init__(
|
||||
self,
|
||||
window: int = 12,
|
||||
confirm_frames: int = 8,
|
||||
lost_frames: int = 12,
|
||||
enter_circle: float = 0.80,
|
||||
exit_circle: float = 0.72,
|
||||
uncertain_after: int = 45,
|
||||
fallback: Category = Category.OVERSIZE,
|
||||
) -> None:
|
||||
self.window = max(5, int(window))
|
||||
self.confirm_frames = max(3, int(confirm_frames))
|
||||
self.lost_frames = max(3, int(lost_frames))
|
||||
self.enter_circle = float(enter_circle)
|
||||
self.exit_circle = float(exit_circle)
|
||||
# нет консенсуса за uncertain_after кадров → безопасная зона (ТЗ:
|
||||
# неоднозначные товары не должны идти в основной поток)
|
||||
self.uncertain_after = max(self.window + 5, int(uncertain_after))
|
||||
self.fallback = fallback
|
||||
|
||||
self._ratios: Deque[float] = deque(maxlen=self.window)
|
||||
self._tops: Deque[float] = deque(maxlen=self.window)
|
||||
self._secs: Deque[float] = deque(maxlen=self.window)
|
||||
self._Ls: Deque[float] = deque(maxlen=self.window)
|
||||
self._Ws: Deque[float] = deque(maxlen=self.window)
|
||||
self._Hs: Deque[float] = deque(maxlen=self.window)
|
||||
self._zones: Deque[str] = deque(maxlen=self.window)
|
||||
|
||||
self._pending_zone: Optional[str] = None
|
||||
self._pending_count: int = 0
|
||||
self._upgrade_count: int = 0
|
||||
self._locked: Optional[ClassificationResult] = None
|
||||
self._miss: int = 0
|
||||
self._frames_seen: int = 0
|
||||
self._uncertain_locked: bool = False
|
||||
|
||||
def reset(self) -> None:
|
||||
self._ratios.clear()
|
||||
self._tops.clear()
|
||||
self._secs.clear()
|
||||
self._Ls.clear()
|
||||
self._Ws.clear()
|
||||
self._Hs.clear()
|
||||
self._zones.clear()
|
||||
self._pending_zone = None
|
||||
self._pending_count = 0
|
||||
self._upgrade_count = 0
|
||||
self._locked = None
|
||||
self._miss = 0
|
||||
self._frames_seen = 0
|
||||
self._uncertain_locked = False
|
||||
|
||||
def update(
|
||||
self,
|
||||
measurement: Optional[ObjectMeasurement],
|
||||
min_mm: Sequence[float] = (10, 10, 10),
|
||||
max_mm: Sequence[float] = (450, 320, 320),
|
||||
) -> StableDecision:
|
||||
if measurement is None:
|
||||
self._miss += 1
|
||||
if self._miss >= self.lost_frames:
|
||||
self.reset()
|
||||
return StableDecision(None, False, 0, 0.0, False)
|
||||
if self._locked is not None:
|
||||
return StableDecision(
|
||||
self._locked, True, 100, self._locked.circle_ratio, True,
|
||||
uncertain=self._uncertain_locked,
|
||||
)
|
||||
return StableDecision(None, False, 0, 0.0, False)
|
||||
|
||||
self._miss = 0
|
||||
self._frames_seen += 1
|
||||
self._ratios.append(float(measurement.circle_ratio))
|
||||
self._tops.append(float(getattr(measurement, "top_ratio", measurement.circle_ratio)))
|
||||
self._secs.append(float(getattr(measurement, "section_ratio", 0.0)))
|
||||
self._Ls.append(float(measurement.length_mm))
|
||||
self._Ws.append(float(measurement.width_mm))
|
||||
self._Hs.append(float(measurement.height_mm))
|
||||
|
||||
ratio = _median(self._ratios)
|
||||
top_m = _median(self._tops)
|
||||
sec_m = _median(self._secs)
|
||||
ratio_p75 = _percentile(self._ratios, 75)
|
||||
# устойчивый круг: медиана >= 0.8 ИЛИ (медиана сечений >= 0.8 и ≥ половины окна сильные)
|
||||
sec_strong = (
|
||||
sum(1 for x in self._secs if x >= self.enter_circle) / max(1, len(self._secs))
|
||||
)
|
||||
circular = ratio >= self.enter_circle or (
|
||||
sec_m >= self.enter_circle and sec_strong >= 0.55
|
||||
)
|
||||
ratio_show = max(ratio, sec_m) if circular else ratio
|
||||
|
||||
L, W, H = _median(self._Ls), _median(self._Ws), _median(self._Hs)
|
||||
dims = tuple(sorted([L, W, H], reverse=True))
|
||||
passes = check_size(dims, min_mm, max_mm)
|
||||
clipped = bool(getattr(measurement, "clipped_by_frame", False))
|
||||
if clipped:
|
||||
# неполный габарит из-за края кадра → безопасный негабарит
|
||||
passes = False
|
||||
|
||||
if not passes:
|
||||
instant = ClassificationResult(
|
||||
category=Category.OVERSIZE,
|
||||
dims_sorted_mm=(dims[0], dims[1], dims[2]),
|
||||
circle_ratio=ratio_show,
|
||||
passes_size=False,
|
||||
is_circular=circular,
|
||||
reason=(
|
||||
"объект обрезан краем кадра → габарит неполный, считаем негабаритом"
|
||||
if clipped
|
||||
else "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм"
|
||||
),
|
||||
)
|
||||
elif circular:
|
||||
instant = ClassificationResult(
|
||||
category=Category.NEED_PACK,
|
||||
dims_sorted_mm=(dims[0], dims[1], dims[2]),
|
||||
circle_ratio=ratio_show,
|
||||
passes_size=True,
|
||||
is_circular=True,
|
||||
reason=(
|
||||
f"круг: med={ratio:.3f} p75={ratio_p75:.3f} "
|
||||
f"top={top_m:.3f} sec={sec_m:.3f} strong={sec_strong:.0%} "
|
||||
f">= {self.enter_circle}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
instant = ClassificationResult(
|
||||
category=Category.SUITABLE,
|
||||
dims_sorted_mm=(dims[0], dims[1], dims[2]),
|
||||
circle_ratio=ratio_show,
|
||||
passes_size=True,
|
||||
is_circular=False,
|
||||
reason=(
|
||||
f"не круг: med={ratio:.3f} sec={sec_m:.3f} "
|
||||
f"strong={sec_strong:.0%} < {self.enter_circle}"
|
||||
),
|
||||
)
|
||||
|
||||
zone = instant.category.zone
|
||||
self._zones.append(zone)
|
||||
|
||||
# --- уже есть LOCK ---
|
||||
if self._locked is not None:
|
||||
prev = self._locked.category
|
||||
cur = instant.category
|
||||
locked_dims = self._locked.dims_sorted_mm
|
||||
|
||||
# новый объект (габариты сильно сменились) — сброс LOCK и набор заново
|
||||
if _dims_changed(locked_dims, dims, rel=0.28):
|
||||
self._locked = None
|
||||
self._uncertain_locked = False
|
||||
self._pending_zone = zone
|
||||
self._pending_count = 1
|
||||
self._upgrade_count = 0
|
||||
self._frames_seen = 1
|
||||
conf = int(min(99, round(100.0 * 1 / max(1, self.confirm_frames))))
|
||||
return StableDecision(instant, False, conf, ratio_show, True)
|
||||
|
||||
can_upgrade = (
|
||||
(prev == Category.OVERSIZE and cur in (Category.SUITABLE, Category.NEED_PACK))
|
||||
or (prev == Category.SUITABLE and cur == Category.NEED_PACK)
|
||||
)
|
||||
if can_upgrade:
|
||||
self._upgrade_count += 1
|
||||
need_up = max(5, self.confirm_frames // 2)
|
||||
if self._upgrade_count >= need_up:
|
||||
self._locked = instant
|
||||
self._upgrade_count = 0
|
||||
self._uncertain_locked = False # появился консенсус
|
||||
else:
|
||||
self._upgrade_count = 0
|
||||
return StableDecision(
|
||||
self._locked, True, 100, ratio_show, True,
|
||||
uncertain=self._uncertain_locked,
|
||||
)
|
||||
|
||||
# нет консенсуса слишком долго → «неуверенно», безопасная зона
|
||||
if self._frames_seen >= self.uncertain_after:
|
||||
fallback_result = ClassificationResult(
|
||||
category=self.fallback,
|
||||
dims_sorted_mm=(dims[0], dims[1], dims[2]),
|
||||
circle_ratio=ratio_show,
|
||||
passes_size=passes,
|
||||
is_circular=circular,
|
||||
reason="НЕУВЕРЕННО → безопасная зона: " + self._uncertain_reason(
|
||||
ratio, dims, min_mm, max_mm
|
||||
),
|
||||
)
|
||||
self._locked = fallback_result
|
||||
self._uncertain_locked = True
|
||||
return StableDecision(fallback_result, True, 100, ratio_show, True, uncertain=True)
|
||||
|
||||
# прогрев окна
|
||||
if len(self._ratios) < self.window:
|
||||
conf = int(min(99, round(100.0 * len(self._ratios) / self.window)))
|
||||
return StableDecision(instant, False, conf, ratio_show, True)
|
||||
|
||||
votes = Counter(self._zones)
|
||||
winner, win_n = votes.most_common(1)[0]
|
||||
if winner != zone:
|
||||
self._pending_zone = None
|
||||
self._pending_count = 0
|
||||
conf = int(round(100.0 * win_n / len(self._zones)))
|
||||
return StableDecision(instant, False, conf, ratio_show, True)
|
||||
|
||||
need = max(self.confirm_frames, (self.window * 2 + 2) // 3)
|
||||
if zone == self._pending_zone:
|
||||
self._pending_count += 1
|
||||
else:
|
||||
self._pending_zone = zone
|
||||
self._pending_count = 1
|
||||
|
||||
conf = int(min(100, round(100.0 * max(self._pending_count, win_n) / need)))
|
||||
if self._pending_count >= need and win_n >= need:
|
||||
self._locked = instant
|
||||
self._uncertain_locked = False
|
||||
return StableDecision(instant, True, 100, ratio_show, True)
|
||||
|
||||
return StableDecision(instant, False, conf, ratio_show, True)
|
||||
|
||||
def _uncertain_reason(
|
||||
self,
|
||||
ratio: float,
|
||||
dims: Sequence[float],
|
||||
min_mm: Sequence[float],
|
||||
max_mm: Sequence[float],
|
||||
) -> str:
|
||||
votes = Counter(self._zones)
|
||||
parts = [
|
||||
f"нет консенсуса {self._frames_seen} кадров",
|
||||
"голоса " + " ".join(f"{z}:{n}" for z, n in votes.most_common()),
|
||||
]
|
||||
if abs(ratio - self.enter_circle) <= 0.06:
|
||||
parts.append(f"ratio {ratio:.3f} у порога {self.enter_circle}")
|
||||
max_s = sorted([float(x) for x in max_mm], reverse=True)
|
||||
min_s = sorted([float(x) for x in min_mm], reverse=True)
|
||||
for d, mx, mn in zip(dims, max_s, min_s):
|
||||
if abs(d - mx) <= 0.05 * mx:
|
||||
parts.append(f"сторона {d:.0f} мм у лимита {mx:.0f}")
|
||||
elif mn > 0 and abs(d - mn) <= max(3.0, 0.3 * mn):
|
||||
parts.append(f"сторона {d:.0f} мм у минимума {mn:.0f}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _median(vals: Deque[float]) -> float:
|
||||
return _percentile(vals, 50)
|
||||
|
||||
|
||||
def _dims_changed(
|
||||
a: Sequence[float], b: Sequence[float], rel: float = 0.28
|
||||
) -> bool:
|
||||
"""True если хотя бы одна сторона изменилась больше чем на rel (новый объект)."""
|
||||
if len(a) < 3 or len(b) < 3:
|
||||
return False
|
||||
for x, y in zip(a[:3], b[:3]):
|
||||
base = max(abs(float(x)), abs(float(y)), 1.0)
|
||||
if abs(float(x) - float(y)) / base > rel:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _percentile(vals: Deque[float], q: float) -> float:
|
||||
arr = sorted(vals)
|
||||
n = len(arr)
|
||||
if n == 0:
|
||||
return 0.0
|
||||
if n == 1:
|
||||
return float(arr[0])
|
||||
pos = (q / 100.0) * (n - 1)
|
||||
lo = int(pos)
|
||||
hi = min(lo + 1, n - 1)
|
||||
frac = pos - lo
|
||||
return float(arr[lo] * (1 - frac) + arr[hi] * frac)
|
||||
86
vision_classifier/test_classify.py
Executable file
86
vision_classifier/test_classify.py
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Юнит-тесты правил классификации (без камеры)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from classify import Category, classify_from_dims
|
||||
|
||||
|
||||
def test_suitable_box():
|
||||
r = classify_from_dims(120, 80, 40, circle_ratio=0.5)
|
||||
assert r.category == Category.SUITABLE
|
||||
assert r.category.zone == "B"
|
||||
|
||||
|
||||
def test_oversize_priority_over_circle():
|
||||
# Большой цилиндр: габариты важнее круга
|
||||
r = classify_from_dims(500, 100, 100, circle_ratio=0.95)
|
||||
assert r.category == Category.OVERSIZE
|
||||
assert r.category.zone == "C"
|
||||
|
||||
|
||||
def test_too_small():
|
||||
r = classify_from_dims(5, 5, 5, circle_ratio=0.2)
|
||||
assert r.category == Category.OVERSIZE
|
||||
|
||||
|
||||
def test_need_pack_cylinder():
|
||||
r = classify_from_dims(100, 50, 50, circle_ratio=0.85)
|
||||
assert r.category == Category.NEED_PACK
|
||||
assert r.category.zone == "D"
|
||||
|
||||
|
||||
def test_lying_bottle_must_be_D_not_B():
|
||||
"""ТЗ: круг в ЛЮБОМ сечении. Лежачая бутылка сверху не круг, но сечение круглое."""
|
||||
# имитация: top-view низкий, но итоговый circle_ratio после 3D-срезов высокий
|
||||
r = classify_from_dims(220, 70, 70, circle_ratio=0.86)
|
||||
assert r.category == Category.NEED_PACK
|
||||
assert r.category.zone == "D"
|
||||
|
||||
|
||||
def test_border_circle():
|
||||
r = classify_from_dims(100, 50, 50, circle_ratio=0.8)
|
||||
assert r.category == Category.NEED_PACK
|
||||
r2 = classify_from_dims(100, 50, 50, circle_ratio=0.799)
|
||||
assert r2.category == Category.SUITABLE
|
||||
|
||||
|
||||
def test_border_dims_strict():
|
||||
"""ТЗ: строго больше 10×10×10 и строго меньше 450×320×320."""
|
||||
# ровно на максимуме → C
|
||||
assert classify_from_dims(450, 320, 320, 0.3).category == Category.OVERSIZE
|
||||
# ровно на минимуме → C
|
||||
assert classify_from_dims(10, 10, 10, 0.3).category == Category.OVERSIZE
|
||||
# чуть внутри границ → B
|
||||
assert classify_from_dims(449, 319, 319, 0.3).category == Category.SUITABLE
|
||||
assert classify_from_dims(11, 11, 11, 0.3).category == Category.SUITABLE
|
||||
# 321 мм влезает вдоль оси 450 → B (сопоставление после сортировки)
|
||||
assert classify_from_dims(100, 321, 100, 0.3).category == Category.SUITABLE
|
||||
# а вот две стороны > 320 уже не влезают → C
|
||||
assert classify_from_dims(400, 330, 100, 0.3).category == Category.OVERSIZE
|
||||
|
||||
|
||||
def test_dims_order_independent():
|
||||
"""Стороны сопоставляются после сортировки — порядок L/W/H не важен."""
|
||||
assert classify_from_dims(319, 449, 318, 0.3).category == Category.SUITABLE
|
||||
assert classify_from_dims(318, 319, 449, 0.3).category == Category.SUITABLE
|
||||
# ровно 320 по строгому правилу «меньше» → C, в любом порядке
|
||||
assert classify_from_dims(320, 449, 319, 0.3).category == Category.OVERSIZE
|
||||
assert classify_from_dims(319, 320, 449, 0.3).category == Category.OVERSIZE
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_suitable_box()
|
||||
test_oversize_priority_over_circle()
|
||||
test_too_small()
|
||||
test_need_pack_cylinder()
|
||||
test_lying_bottle_must_be_D_not_B()
|
||||
test_border_circle()
|
||||
test_border_dims_strict()
|
||||
test_dims_order_independent()
|
||||
print("OK: all classification tests passed")
|
||||
384
vision_classifier/test_geometry.py
Normal file
384
vision_classifier/test_geometry.py
Normal file
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Геометрические тесты: прямоугольник → B, круг → D (без камеры)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from measure import (
|
||||
ObjectMeasurement,
|
||||
_fit_circle_arc,
|
||||
_section_score,
|
||||
measure_object,
|
||||
rin_rout,
|
||||
section_rin_rout,
|
||||
segment_object,
|
||||
)
|
||||
from stabilize import DecisionStabilizer
|
||||
|
||||
|
||||
def _circle(n=64, r=40.0):
|
||||
a = np.linspace(0, 2 * np.pi, n, endpoint=False)
|
||||
return np.column_stack([50 + r * np.cos(a), 50 + r * np.sin(a)]).astype(np.float32)
|
||||
|
||||
|
||||
def _rect(w=150.0, h=90.0, n=20):
|
||||
pts = (
|
||||
[[x, 0] for x in np.linspace(0, w, n)]
|
||||
+ [[w, y] for y in np.linspace(0, h, n)]
|
||||
+ [[x, h] for x in np.linspace(w, 0, n)]
|
||||
+ [[0, y] for y in np.linspace(h, 0, n)]
|
||||
)
|
||||
return np.array(pts, dtype=np.float32)
|
||||
|
||||
|
||||
def _round_rect(w=150.0, h=90.0, rad=8.0, n=12, ne=15):
|
||||
pts = []
|
||||
pts += [[x, 0] for x in np.linspace(rad, w - rad, ne)]
|
||||
pts += [[w, y] for y in np.linspace(rad, h - rad, ne)]
|
||||
pts += [[x, h] for x in np.linspace(w - rad, rad, ne)]
|
||||
pts += [[0, y] for y in np.linspace(h - rad, rad, ne)]
|
||||
corners = [
|
||||
(rad, rad, np.pi, 1.5 * np.pi),
|
||||
(w - rad, rad, 1.5 * np.pi, 2 * np.pi),
|
||||
(w - rad, h - rad, 0, 0.5 * np.pi),
|
||||
(rad, h - rad, 0.5 * np.pi, np.pi),
|
||||
]
|
||||
for cx, cy, a0, a1 in corners:
|
||||
for a in np.linspace(a0, a1, n):
|
||||
pts.append([cx + rad * np.cos(a), cy + rad * np.sin(a)])
|
||||
return np.array(pts, dtype=np.float32)
|
||||
|
||||
|
||||
def _arc(span_deg=252.0, r=35.0, n=80):
|
||||
a0 = -np.deg2rad(span_deg) / 2
|
||||
a1 = np.deg2rad(span_deg) / 2
|
||||
a = np.linspace(a0, a1, n)
|
||||
return np.column_stack([50 + r * np.cos(a), 50 + r * np.sin(a)]).astype(np.float32)
|
||||
|
||||
|
||||
def test_geometry_ratios():
|
||||
assert rin_rout(_circle()) >= 0.8, "круг сверху должен быть D"
|
||||
assert rin_rout(_rect()) < 0.8, "прямоугольник должен быть B"
|
||||
assert rin_rout(_round_rect()) < 0.8, "скруглённый прямоугольник — B"
|
||||
# квадрат < 0.8 (теоретически ~0.707)
|
||||
sq = np.array([[0, 0], [100, 0], [100, 100], [0, 100]], dtype=np.float32)
|
||||
assert rin_rout(sq) < 0.8
|
||||
|
||||
# дуга цилиндра: fit → 0.85
|
||||
arc = _arc()
|
||||
score = _section_score(arc)
|
||||
assert score >= 0.8, f"дуга цилиндра должна давать >=0.8, got {score}"
|
||||
assert _fit_circle_arc(_rect()) is None, "прямоугольник не должен проходить circle-fit"
|
||||
|
||||
|
||||
def test_stabilizer_box_vs_cylinder():
|
||||
dummy = np.zeros((10, 1, 2), np.int32)
|
||||
mask = np.zeros((10, 10), np.uint8)
|
||||
|
||||
# коробка с редкими ложными пиками → LOCK B
|
||||
s = DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
|
||||
locked_zone = None
|
||||
seq = [0.55, 0.84, 0.56, 0.58, 0.57, 0.59, 0.55, 0.56, 0.58, 0.57, 0.55, 0.56, 0.57, 0.58]
|
||||
for r in seq:
|
||||
m = ObjectMeasurement(120, 80, 40, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.5)
|
||||
d = s.update(m)
|
||||
if d.locked and d.result:
|
||||
locked_zone = d.result.category.zone
|
||||
assert locked_zone == "B", f"коробка должна LOCK B, got {locked_zone}"
|
||||
|
||||
# цилиндр → LOCK D
|
||||
s2 = DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
|
||||
locked_zone = None
|
||||
for r in [0.90] * 16:
|
||||
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.88)
|
||||
d = s2.update(m)
|
||||
if d.locked and d.result:
|
||||
locked_zone = d.result.category.zone
|
||||
assert locked_zone == "D", f"цилиндр должен LOCK D, got {locked_zone}"
|
||||
|
||||
# после LOCK D не прыгаем в B
|
||||
for r in [0.4] * 10:
|
||||
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.4)
|
||||
d = s2.update(m)
|
||||
assert d.locked and d.result and d.result.category.zone == "D"
|
||||
|
||||
# early B, then устойчивый круг → апгрейд в D (те же габариты)
|
||||
s3 = DecisionStabilizer(window=8, confirm_frames=6, enter_circle=0.8)
|
||||
for r in [0.55] * 20:
|
||||
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.5)
|
||||
d = s3.update(m)
|
||||
assert d.locked and d.result.category.zone == "B"
|
||||
for r in [0.86] * 16:
|
||||
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=0.72, section_ratio=0.86)
|
||||
d = s3.update(m)
|
||||
assert d.locked and d.result.category.zone == "D", f"ожидали апгрейд B→D, got {d.result.category.zone}"
|
||||
|
||||
# смена объекта круг→коробка по габаритам → новый LOCK B
|
||||
saw_reset = False
|
||||
locked_b = False
|
||||
for r in [0.45] * 25:
|
||||
m = ObjectMeasurement(240, 120, 50, r, 1000, (1, 1), dummy, mask, top_ratio=0.45, section_ratio=0.27)
|
||||
d = s3.update(m)
|
||||
if d.present and not d.locked:
|
||||
saw_reset = True
|
||||
if d.locked and d.result and d.result.category.zone == "B":
|
||||
locked_b = True
|
||||
assert saw_reset, "при смене габаритов должен быть сброс LOCK"
|
||||
assert locked_b, "коробка после смены должна LOCK B"
|
||||
|
||||
|
||||
def test_background_map_splits_object_from_platform():
|
||||
"""Цилиндр на платформе: скалярная высота сливает их, фоновая карта — нет."""
|
||||
H, W = 480, 640
|
||||
belt = 600
|
||||
bg = np.full((H, W), belt, np.uint16)
|
||||
bg[100:340, 130:470] = belt - 80 # платформа 80 мм — часть фона
|
||||
|
||||
depth = bg.copy()
|
||||
yy, xx = np.ogrid[:H, :W]
|
||||
circ = (yy - 220) ** 2 + (xx - 300) ** 2 <= 70**2
|
||||
depth[circ] = belt - 80 - 60 # круглый предмет 60 мм на платформе
|
||||
|
||||
# старый способ: платформа+цилиндр в одном контуре (большая площадь)
|
||||
seg_old = segment_object(depth, belt_distance_mm=belt, min_area_px=400)
|
||||
assert seg_old is not None
|
||||
area_old = int((seg_old[0] > 0).sum())
|
||||
assert area_old > 240 * 340 * 0.8, "скалярный способ должен захватить платформу"
|
||||
|
||||
# с фоновой картой: только цилиндр
|
||||
seg_bg = segment_object(depth, belt_distance_mm=belt, min_area_px=400, background_mm=bg)
|
||||
assert seg_bg is not None
|
||||
mask, contour = seg_bg
|
||||
area = int((mask > 0).sum())
|
||||
circle_area = np.pi * 70 * 70
|
||||
assert abs(area - circle_area) / circle_area < 0.15, f"площадь {area} vs круг {circle_area:.0f}"
|
||||
|
||||
m = measure_object(
|
||||
depth, mask, contour,
|
||||
belt_distance_mm=belt, fx=670, fy=670, cx=320, cy=240,
|
||||
background_mm=bg,
|
||||
)
|
||||
assert m is not None
|
||||
assert abs(m.height_mm - 60) < 8, f"высота от платформы должна быть ~60, got {m.height_mm:.1f}"
|
||||
assert m.top_ratio >= 0.8, f"вид сверху круг, got {m.top_ratio:.3f}"
|
||||
|
||||
|
||||
def test_uncertain_fallback_to_safe_zone():
|
||||
"""Нет консенсуса (ratio скачет у порога) → «неуверенно» → безопасная зона C."""
|
||||
from classify import Category
|
||||
|
||||
dummy = np.zeros((10, 1, 2), np.int32)
|
||||
mask = np.zeros((10, 10), np.uint8)
|
||||
|
||||
s = DecisionStabilizer(window=8, confirm_frames=6, uncertain_after=25, fallback=Category.OVERSIZE)
|
||||
seq = ([0.70] * 6 + [0.90] * 6) * 5 # блоками вокруг порога 0.8
|
||||
final = None
|
||||
for i, r in enumerate(seq):
|
||||
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=r)
|
||||
d = s.update(m)
|
||||
if d.locked:
|
||||
final = (i + 1, d)
|
||||
break
|
||||
assert final is not None, "fallback должен сработать"
|
||||
n_frames, d = final
|
||||
assert d.uncertain, "LOCK должен быть помечен как неуверенный"
|
||||
assert d.result is not None and d.result.category.zone == "C"
|
||||
assert "НЕУВЕРЕННО" in d.result.reason
|
||||
assert n_frames <= 30, f"fallback должен сработать около 25 кадров, got {n_frames}"
|
||||
|
||||
# уверенная коробка не должна помечаться «неуверенно»
|
||||
s2 = DecisionStabilizer(window=8, confirm_frames=6, uncertain_after=25)
|
||||
got = None
|
||||
for r in [0.5] * 20:
|
||||
m = ObjectMeasurement(120, 80, 40, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=r)
|
||||
d2 = s2.update(m)
|
||||
if d2.locked:
|
||||
got = d2
|
||||
break
|
||||
assert got is not None and not got.uncertain
|
||||
assert got.result is not None and got.result.category.zone == "B"
|
||||
|
||||
|
||||
def test_tracker_two_objects_ids_and_stats():
|
||||
"""Коробка + цилиндр одновременно: два ID, статистика считает каждого один раз."""
|
||||
from tracker import MultiObjectTracker
|
||||
|
||||
dummy = np.zeros((10, 1, 2), np.int32)
|
||||
mask = np.zeros((10, 10), np.uint8)
|
||||
|
||||
def factory():
|
||||
return DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
|
||||
|
||||
t = MultiObjectTracker(factory, max_dist_px=120, lost_frames=8)
|
||||
all_events = []
|
||||
for _ in range(20):
|
||||
box = ObjectMeasurement(120, 80, 40, 0.55, 1000, (100, 100), dummy, mask,
|
||||
top_ratio=0.55, section_ratio=0.5)
|
||||
cyl = ObjectMeasurement(100, 50, 50, 0.90, 900, (500, 300), dummy, mask,
|
||||
top_ratio=0.90, section_ratio=0.88)
|
||||
tracks, events = t.update([box, cyl])
|
||||
all_events.extend(events)
|
||||
|
||||
assert len(tracks) == 2, f"должно быть 2 трека, got {len(tracks)}"
|
||||
ids = sorted(tr.track_id for tr in tracks)
|
||||
assert ids == [1, 2], f"ID должны быть 1 и 2, got {ids}"
|
||||
zones = sorted(ev.decision.result.category.zone for ev in all_events)
|
||||
assert zones == ["B", "D"], f"события LOCK для B и D, got {zones}"
|
||||
assert t.stats["B"] == 1 and t.stats["D"] == 1 and t.stats["total"] == 2
|
||||
|
||||
# объекты убрали → треки умирают (залоченные живут дольше), статистика остаётся
|
||||
for _ in range(40):
|
||||
tracks, _ = t.update([])
|
||||
assert not tracks
|
||||
assert t.stats["total"] == 2
|
||||
|
||||
# тот же товар на том же месте вернулся → без нового LOCK и без роста счётчика
|
||||
events_back = []
|
||||
for _ in range(15):
|
||||
box = ObjectMeasurement(120, 80, 40, 0.55, 1000, (100, 100), dummy, mask,
|
||||
top_ratio=0.55, section_ratio=0.5)
|
||||
tracks, events = t.update([box])
|
||||
events_back.extend(events)
|
||||
assert not events_back, "повторный захват уже учтённого товара не должен давать LOCK"
|
||||
assert tracks and tracks[0].track_id == 1
|
||||
assert tracks[0].frozen is not None and tracks[0].decision.locked
|
||||
assert t.stats["B"] == 1 and t.stats["total"] == 2
|
||||
|
||||
# новый объект в другом месте получает следующий ID
|
||||
box2 = ObjectMeasurement(200, 100, 60, 0.5, 1200, (300, 200), dummy, mask,
|
||||
top_ratio=0.5, section_ratio=0.4)
|
||||
for _ in range(20):
|
||||
tracks, events = t.update([box2])
|
||||
assert tracks and any(tr.track_id == 3 for tr in tracks)
|
||||
assert t.stats["B"] == 2 and t.stats["total"] == 3
|
||||
|
||||
|
||||
def test_tracker_slot_dedup_same_object():
|
||||
"""Тот же товар на том же месте с новым track_id — без повторного LOCK."""
|
||||
from tracker import MultiObjectTracker, slot_key
|
||||
|
||||
dummy = np.zeros((10, 1, 2), np.int32)
|
||||
mask = np.zeros((10, 10), np.uint8)
|
||||
|
||||
def factory():
|
||||
return DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
|
||||
|
||||
t = MultiObjectTracker(factory, max_dist_px=120, lost_frames=8)
|
||||
box = ObjectMeasurement(120, 80, 40, 0.55, 1000, (100, 100), dummy, mask,
|
||||
top_ratio=0.55, section_ratio=0.5)
|
||||
all_events = []
|
||||
for _ in range(20):
|
||||
tracks, events = t.update([box])
|
||||
all_events.extend(events)
|
||||
assert all_events, "первый LOCK должен быть"
|
||||
sk = slot_key(100, 100, 120, 80, 40, "B")
|
||||
assert sk in t._seen_slots
|
||||
|
||||
# новый track_id, та же позиция — события нет, счётчик не растёт
|
||||
for _ in range(40):
|
||||
tracks, _ = t.update([])
|
||||
box2 = ObjectMeasurement(118, 82, 41, 0.56, 1000, (102, 98), dummy, mask,
|
||||
top_ratio=0.56, section_ratio=0.5)
|
||||
extra = []
|
||||
for _ in range(20):
|
||||
tracks, events = t.update([box2])
|
||||
extra.extend(events)
|
||||
assert not extra, "повтор того же слота не должен давать LOCK"
|
||||
assert t.stats["total"] == 1
|
||||
|
||||
|
||||
def test_shadow_not_detected_as_object():
|
||||
"""Тень на ленте (затемнение без смены цвета) не должна давать RGB-объект."""
|
||||
from measure import segment_rgb_objects
|
||||
|
||||
H, W = 480, 640
|
||||
bg = np.full((H, W, 3), (90, 130, 160), np.uint8) # коричневая лента BGR
|
||||
color = bg.copy()
|
||||
# мягкая тень: все каналы ×0.55 — типичная тень от коробки
|
||||
color[150:300, 200:420] = (bg[150:300, 200:420].astype(np.float32) * 0.55).astype(np.uint8)
|
||||
objs = segment_rgb_objects(color, bg, min_area_px=400, diff_threshold=35)
|
||||
assert not objs, f"тень не должна детектироваться, got {len(objs)}"
|
||||
|
||||
|
||||
def test_merge_overlapping_halves():
|
||||
"""Две половины одного объекта с пересечением ≥50% → один контур."""
|
||||
from measure import merge_overlapping_masks, mask_overlap_min
|
||||
|
||||
H, W = 100, 100
|
||||
a = np.zeros((H, W), np.uint8)
|
||||
b = np.zeros((H, W), np.uint8)
|
||||
a[20:60, 20:60] = 255 # 40×40
|
||||
b[20:60, 35:75] = 255 # перекрытие 40×25 = 1000 / 1600 = 0.625
|
||||
assert mask_overlap_min(a, b) >= 0.5
|
||||
ca, _ = cv2.findContours(a, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
cb, _ = cv2.findContours(b, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
merged = merge_overlapping_masks([(a, ca[0]), (b, cb[0])], overlap_thr=0.5)
|
||||
assert len(merged) == 1, f"ожидали 1 объект, got {len(merged)}"
|
||||
|
||||
|
||||
def test_noise_measurement_rejected():
|
||||
"""Шум 0×0×0 / мелкие RGB-пятна не должны классифицироваться как негабарит."""
|
||||
from measure import ObjectMeasurement, is_plausible_measurement
|
||||
|
||||
dummy = np.zeros((10, 1, 2), np.int32)
|
||||
mask = np.zeros((10, 10), np.uint8)
|
||||
noise = ObjectMeasurement(0.0, 0.0, 0.0, 0.1, 80, (10, 10), dummy, mask, source="rgb")
|
||||
assert not is_plausible_measurement(noise)
|
||||
speckle = ObjectMeasurement(12.0, 8.0, 0.0, 0.2, 500, (50, 50), dummy, mask, source="rgb")
|
||||
assert not is_plausible_measurement(speckle)
|
||||
ok = ObjectMeasurement(110.0, 70.0, 28.0, 0.6, 1200, (100, 100), dummy, mask)
|
||||
assert is_plausible_measurement(ok)
|
||||
|
||||
|
||||
def test_flat_phone_via_rgb():
|
||||
"""Телефон 8 мм: depth не видит (порог 12 мм), RGB-фон находит → класс C."""
|
||||
from classify import Category, classify
|
||||
from measure import measure_flat_object, segment_rgb_objects
|
||||
|
||||
H, W = 480, 640
|
||||
belt = 534
|
||||
bg_color = np.full((H, W, 3), 120, np.uint8) # серая лента
|
||||
color = bg_color.copy()
|
||||
x0, y0, pw, ph = 250, 180, 172, 80 # ≈160×75 мм при fx=564, z=534
|
||||
color[y0 : y0 + ph, x0 : x0 + pw] = (30, 30, 30) # тёмный телефон
|
||||
|
||||
depth = np.full((H, W), belt, np.uint16)
|
||||
depth[y0 : y0 + ph, x0 : x0 + pw] = belt - 8 # всего 8 мм над лентой
|
||||
rng = np.random.default_rng(0)
|
||||
depth = (depth.astype(np.int32) + rng.integers(-3, 4, size=depth.shape)).astype(np.uint16)
|
||||
|
||||
objs = segment_rgb_objects(color, bg_color, min_area_px=400)
|
||||
assert objs, "телефон должен найтись по RGB-фону"
|
||||
mask, contour = objs[0]
|
||||
m = measure_flat_object(
|
||||
depth, mask, contour,
|
||||
belt_distance_mm=belt, fx=564.0, fy=564.0, cx=320, cy=240,
|
||||
background_mm=np.full((H, W), belt, np.uint16),
|
||||
)
|
||||
assert m is not None and m.source == "rgb"
|
||||
assert abs(m.length_mm - 160) < 15, f"длина ~160, got {m.length_mm:.0f}"
|
||||
assert abs(m.width_mm - 75) < 12, f"ширина ~75, got {m.width_mm:.0f}"
|
||||
assert m.height_mm < 12, f"высота должна быть маленькой, got {m.height_mm:.0f}"
|
||||
r = classify(m)
|
||||
assert r.category == Category.OVERSIZE, "тоньше 10 мм → C по ТЗ (меньше минимума)"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_geometry_ratios()
|
||||
test_stabilizer_box_vs_cylinder()
|
||||
test_background_map_splits_object_from_platform()
|
||||
test_uncertain_fallback_to_safe_zone()
|
||||
test_tracker_two_objects_ids_and_stats()
|
||||
test_tracker_slot_dedup_same_object()
|
||||
test_noise_measurement_rejected()
|
||||
test_merge_overlapping_halves()
|
||||
test_shadow_not_detected_as_object()
|
||||
test_flat_phone_via_rgb()
|
||||
print("OK: geometry + stabilizer tests passed")
|
||||
279
vision_classifier/tracker.py
Normal file
279
vision_classifier/tracker.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Трекинг нескольких объектов в кадре: ID + статистика зон за сессию.
|
||||
|
||||
Сопоставление кадр-к-кадру по ближайшему центроиду. На каждый трек —
|
||||
свой DecisionStabilizer. LOCK в ленту/MQTT — один раз на товар: после
|
||||
фиксации запоминаем «отпечаток» (центр+габариты); если depth кратковременно
|
||||
пропал и объект нашёлся снова рядом — трек возрождается без нового LOCK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from classify import ClassificationResult
|
||||
from measure import ObjectMeasurement
|
||||
from stabilize import DecisionStabilizer, StableDecision
|
||||
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
track_id: int
|
||||
stabilizer: DecisionStabilizer
|
||||
centroid: Tuple[int, int]
|
||||
miss: int = 0
|
||||
counted_zone: Optional[str] = None
|
||||
counted_uncertain: bool = False
|
||||
measurement: Optional[ObjectMeasurement] = None
|
||||
decision: Optional[StableDecision] = None
|
||||
reported: bool = False # уже ушёл в ленту — повторно не пишем
|
||||
frozen: Optional[StableDecision] = None # снимок первого LOCK (для возрождения)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LockEvent:
|
||||
track_id: int
|
||||
decision: StableDecision
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Fingerprint:
|
||||
"""Товар уже зафиксирован — не считаем повторно при перезахвате."""
|
||||
track_id: int
|
||||
cx: int
|
||||
cy: int
|
||||
length_mm: float
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
zone: str
|
||||
uncertain: bool
|
||||
result: ClassificationResult
|
||||
age: int = 0 # кадров без детекции
|
||||
|
||||
|
||||
def slot_key(cx: int, cy: int, L: float, W: float, H: float, zone: str) -> str:
|
||||
"""Стабильный ключ товара на ленте — один физический предмет = одна запись."""
|
||||
a = sorted((L, W, H))
|
||||
return f"{cx // 35}_{cy // 35}_{int(a[0] // 12)}_{int(a[1] // 12)}_{int(a[2] // 8)}_{zone}"
|
||||
|
||||
|
||||
class MultiObjectTracker:
|
||||
def __init__(
|
||||
self,
|
||||
stabilizer_factory: Callable[[], DecisionStabilizer],
|
||||
max_dist_px: int = 120,
|
||||
lost_frames: int = 12,
|
||||
fingerprint_ttl: int = 450, # ~15–20 с помнить «уже учтён»
|
||||
) -> None:
|
||||
self._factory = stabilizer_factory
|
||||
self.max_dist_px = int(max_dist_px)
|
||||
self.lost_frames = int(lost_frames)
|
||||
self.fingerprint_ttl = int(fingerprint_ttl)
|
||||
self._tracks: Dict[int, Track] = {}
|
||||
self._fps: List[_Fingerprint] = []
|
||||
self._seen_slots: Dict[str, _Fingerprint] = {} # уже учтённые товары (позиция+габариты)
|
||||
self._next_id = 1
|
||||
self.stats: Dict[str, int] = {"B": 0, "C": 0, "D": 0, "total": 0, "uncertain": 0}
|
||||
|
||||
def reset(self) -> None:
|
||||
self._tracks.clear()
|
||||
self._fps.clear()
|
||||
|
||||
def update(
|
||||
self,
|
||||
measurements: List[ObjectMeasurement],
|
||||
min_mm: Sequence[float] = (10, 10, 10),
|
||||
max_mm: Sequence[float] = (450, 320, 320),
|
||||
) -> Tuple[List[Track], List[LockEvent]]:
|
||||
free_meas = list(range(len(measurements)))
|
||||
assigned: Dict[int, int] = {}
|
||||
pairs = []
|
||||
for tid, tr in self._tracks.items():
|
||||
for mi in free_meas:
|
||||
m = measurements[mi]
|
||||
d2 = (tr.centroid[0] - m.centroid_px[0]) ** 2 + (tr.centroid[1] - m.centroid_px[1]) ** 2
|
||||
pairs.append((d2, tid, mi))
|
||||
for d2, tid, mi in sorted(pairs):
|
||||
if tid in assigned or mi not in free_meas:
|
||||
continue
|
||||
if d2 > self.max_dist_px**2:
|
||||
continue
|
||||
assigned[tid] = mi
|
||||
free_meas.remove(mi)
|
||||
|
||||
events: List[LockEvent] = []
|
||||
|
||||
for tid in list(self._tracks.keys()):
|
||||
tr = self._tracks[tid]
|
||||
# залоченный трек держим дольше — глянец даёт короткие выпадения depth
|
||||
kill_after = self.lost_frames * 3 if tr.counted_zone is not None else self.lost_frames
|
||||
if tid in assigned:
|
||||
m = measurements[assigned[tid]]
|
||||
tr.centroid = m.centroid_px
|
||||
tr.miss = 0
|
||||
tr.measurement = m
|
||||
tr.decision = tr.stabilizer.update(m, min_mm=min_mm, max_mm=max_mm)
|
||||
# после возрождения стабилизатор ещё «холодный» — держим прошлый LOCK на экране
|
||||
if tr.reported and tr.frozen is not None and not (tr.decision and tr.decision.locked):
|
||||
tr.decision = tr.frozen
|
||||
self._account(tr, events)
|
||||
if tr.reported:
|
||||
self._touch_fp(tr)
|
||||
else:
|
||||
tr.miss += 1
|
||||
tr.measurement = None
|
||||
tr.decision = tr.stabilizer.update(None, min_mm=min_mm, max_mm=max_mm)
|
||||
if tr.reported and tr.frozen is not None and not (tr.decision and tr.decision.locked):
|
||||
tr.decision = tr.frozen
|
||||
if tr.miss >= kill_after:
|
||||
if tr.reported and tr.frozen is not None and tr.frozen.result is not None:
|
||||
self._remember(tr)
|
||||
del self._tracks[tid]
|
||||
|
||||
# старение отпечатков
|
||||
for fp in self._fps:
|
||||
fp.age += 1
|
||||
self._fps = [fp for fp in self._fps if fp.age < self.fingerprint_ttl]
|
||||
|
||||
for mi in free_meas:
|
||||
m = measurements[mi]
|
||||
fp = self._match_fp(m)
|
||||
if fp is not None:
|
||||
# тот же товар вернулся после выпадения depth — без нового LOCK
|
||||
fp.age = 0
|
||||
fp.cx, fp.cy = m.centroid_px
|
||||
frozen = StableDecision(
|
||||
fp.result, True, 100, fp.result.circle_ratio, True, uncertain=fp.uncertain,
|
||||
)
|
||||
tr = Track(
|
||||
track_id=fp.track_id,
|
||||
stabilizer=self._factory(),
|
||||
centroid=m.centroid_px,
|
||||
measurement=m,
|
||||
counted_zone=fp.zone,
|
||||
counted_uncertain=fp.uncertain,
|
||||
reported=True,
|
||||
frozen=frozen,
|
||||
decision=frozen,
|
||||
)
|
||||
self._tracks[tr.track_id] = tr
|
||||
self._fps = [x for x in self._fps if x.track_id != fp.track_id]
|
||||
continue
|
||||
|
||||
tr = Track(
|
||||
track_id=self._next_id,
|
||||
stabilizer=self._factory(),
|
||||
centroid=m.centroid_px,
|
||||
measurement=m,
|
||||
)
|
||||
self._next_id += 1
|
||||
tr.decision = tr.stabilizer.update(m, min_mm=min_mm, max_mm=max_mm)
|
||||
self._tracks[tr.track_id] = tr
|
||||
self._account(tr, events)
|
||||
|
||||
alive = sorted(self._tracks.values(), key=lambda t: t.track_id)
|
||||
return [t for t in alive if t.measurement is not None or t.miss < (
|
||||
self.lost_frames * 3 if t.counted_zone else self.lost_frames
|
||||
)], events
|
||||
|
||||
def _account(self, tr: Track, events: List[LockEvent]) -> None:
|
||||
d = tr.decision
|
||||
if d is None or not d.locked or d.result is None:
|
||||
return
|
||||
zone = d.result.category.zone
|
||||
L, W, H = d.result.dims_sorted_mm
|
||||
sk = slot_key(tr.centroid[0], tr.centroid[1], L, W, H, zone)
|
||||
if tr.counted_zone is None:
|
||||
if sk in self._seen_slots:
|
||||
# тот же товар уже был в ленте/статистике — только показываем на экране
|
||||
prev = self._seen_slots[sk]
|
||||
tr.counted_zone = prev.zone
|
||||
tr.counted_uncertain = prev.uncertain
|
||||
tr.frozen = StableDecision(
|
||||
prev.result, True, 100, prev.result.circle_ratio, True, uncertain=prev.uncertain,
|
||||
)
|
||||
tr.reported = True
|
||||
tr.decision = tr.frozen
|
||||
return
|
||||
self.stats[zone] = self.stats.get(zone, 0) + 1
|
||||
self.stats["total"] += 1
|
||||
if d.uncertain:
|
||||
self.stats["uncertain"] += 1
|
||||
tr.counted_zone = zone
|
||||
tr.counted_uncertain = d.uncertain
|
||||
tr.frozen = StableDecision(
|
||||
d.result, True, 100, d.result.circle_ratio, True, uncertain=d.uncertain,
|
||||
)
|
||||
self._seen_slots[sk] = _Fingerprint(
|
||||
track_id=tr.track_id,
|
||||
cx=tr.centroid[0],
|
||||
cy=tr.centroid[1],
|
||||
length_mm=float(L),
|
||||
width_mm=float(W),
|
||||
height_mm=float(H),
|
||||
zone=zone,
|
||||
uncertain=d.uncertain,
|
||||
result=d.result,
|
||||
)
|
||||
if not tr.reported:
|
||||
tr.reported = True
|
||||
events.append(LockEvent(tr.track_id, tr.frozen))
|
||||
elif tr.counted_zone != zone:
|
||||
# смена зоны на экране/в счётчиках — в ленту повторно не пишем
|
||||
self.stats[tr.counted_zone] = max(0, self.stats.get(tr.counted_zone, 0) - 1)
|
||||
self.stats[zone] = self.stats.get(zone, 0) + 1
|
||||
if tr.counted_uncertain and not d.uncertain:
|
||||
self.stats["uncertain"] = max(0, self.stats["uncertain"] - 1)
|
||||
tr.counted_uncertain = d.uncertain
|
||||
tr.counted_zone = zone
|
||||
tr.frozen = StableDecision(
|
||||
d.result, True, 100, d.result.circle_ratio, True, uncertain=d.uncertain,
|
||||
)
|
||||
|
||||
def _remember(self, tr: Track) -> None:
|
||||
assert tr.frozen is not None and tr.frozen.result is not None
|
||||
r = tr.frozen.result
|
||||
L, W, H = r.dims_sorted_mm
|
||||
self._fps = [fp for fp in self._fps if fp.track_id != tr.track_id]
|
||||
self._fps.append(
|
||||
_Fingerprint(
|
||||
track_id=tr.track_id,
|
||||
cx=tr.centroid[0],
|
||||
cy=tr.centroid[1],
|
||||
length_mm=float(L),
|
||||
width_mm=float(W),
|
||||
height_mm=float(H),
|
||||
zone=tr.counted_zone or r.category.zone,
|
||||
uncertain=tr.counted_uncertain,
|
||||
result=r,
|
||||
age=0,
|
||||
)
|
||||
)
|
||||
|
||||
def _touch_fp(self, tr: Track) -> None:
|
||||
for fp in self._fps:
|
||||
if fp.track_id == tr.track_id:
|
||||
fp.age = 0
|
||||
fp.cx, fp.cy = tr.centroid
|
||||
|
||||
def _match_fp(self, m: ObjectMeasurement) -> Optional[_Fingerprint]:
|
||||
best: Optional[_Fingerprint] = None
|
||||
best_d2 = self.max_dist_px**2
|
||||
for fp in self._fps:
|
||||
d2 = (fp.cx - m.centroid_px[0]) ** 2 + (fp.cy - m.centroid_px[1]) ** 2
|
||||
if d2 > best_d2:
|
||||
continue
|
||||
if not _dims_close(fp.length_mm, fp.width_mm, fp.height_mm, m.length_mm, m.width_mm, m.height_mm):
|
||||
continue
|
||||
best, best_d2 = fp, d2
|
||||
return best
|
||||
|
||||
|
||||
def _dims_close(L0: float, W0: float, H0: float, L1: float, W1: float, H1: float, tol: float = 0.40) -> bool:
|
||||
"""Габариты «похожи» (порядок осей уже отсортирован в classify, здесь — сырые L×W×H)."""
|
||||
a = sorted((L0, W0, H0))
|
||||
b = sorted((L1, W1, H1))
|
||||
for x, y in zip(a, b):
|
||||
if abs(x - y) / max(x, y, 1.0) > tol:
|
||||
return False
|
||||
return True
|
||||
Reference in New Issue
Block a user