37 Commits

Author SHA1 Message Date
8aa0c16359 vision model 2026-08-03 01:26:42 +07:00
3f8f3a40ec minor fix 2026-08-03 01:25:02 +07:00
e451b84450 fix: CWE-119 2026-07-16 03:46:24 +03:00
988a9ebbf1 fix: minor change schematics 2026-07-16 03:21:17 +03:00
4aeec2bee7 Update: gui actulization contol panel 2026-07-16 03:20:26 +03:00
3b9fc4c874 New: adding vl53l0x sensor 2026-07-16 03:18:20 +03:00
2831c396b9 Update: actualize schematic 2026-07-15 23:47:51 +03:00
31ca8bacd4 Update: actual control program 2026-07-15 23:45:56 +03:00
ff51eccf22 Update: replace board, add servo driver program 2026-07-15 23:44:36 +03:00
50638a11be final: the end of 3d modelling 2026-07-12 12:47:54 +03:00
6b395e083e update: new models 2026-07-12 01:39:08 +03:00
6f57372d30 update: fix model 2026-07-10 03:40:41 +07:00
2618bd9a7f update: create new models 2026-07-09 14:48:40 +07:00
f38e3ff273 Update: adding al profile and some models 2026-07-08 20:36:15 +07:00
422d438b85 New: 3D models 2026-07-08 05:01:29 +07:00
a75d74cab4 New: Create 3D model sptep-drive nema17 2026-07-08 00:39:59 +07:00
256dfbe280 fix: minor change schematic 2026-07-08 00:00:37 +07:00
00d60d03fb Update: update specification 2026-07-07 23:57:00 +07:00
0a0c5915ca fix: change pin number, for spare i2c communication 2026-07-07 23:55:54 +07:00
fd8be02416 New: adding TOF laser and more servo-drive 2026-07-07 23:54:11 +07:00
c62ebaae16 New: adding servo-drive 2026-07-07 21:56:00 +07:00
893f881419 fix: update gui interface 2026-07-07 21:54:53 +07:00
023e699771 New: adding control servo-drive 2026-07-07 21:54:03 +07:00
b69fe70122 Update schematic 2026-07-07 17:25:57 +07:00
70bdfc1c5c minor edit 2026-07-07 17:24:06 +07:00
ff49b03be2 Create gui interface for controll step-drive 2026-07-07 17:23:07 +07:00
15645f7d25 Switch controlling step driver to serial interface 2026-07-07 05:31:54 +07:00
2c4aae9523 delete unused command 2026-07-07 05:31:02 +07:00
e546707069 Update to actual schematic 2026-07-07 00:20:54 +07:00
5fa88eaa56 update script 2026-07-06 23:12:12 +07:00
9c522fb95c refactoring codebase 2026-07-06 23:11:34 +07:00
77997736f1 update script 2026-07-06 21:36:58 +07:00
8f43b9f455 adding more mqtt topics 2026-07-06 21:36:09 +07:00
6ffc2e606b adding backend script + test program for microcontroller 2026-07-06 18:25:06 +07:00
d09e1a58e5 adding README and gitignore files 2026-07-06 18:24:11 +07:00
93dce8e237 Добавил mindmap 2026-07-04 21:16:40 +07:00
e923afd4c7 Добавил набросок конвейера, примерный список запчастей. И записал возможные проблемы и решение от ии 2026-07-03 02:39:23 +07:00
65 changed files with 15714 additions and 0 deletions

2
3d_models/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.FCBak
/export

BIN
3d_models/conveer.FCStd Normal file

Binary file not shown.

View File

@@ -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
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

View 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"
]
}

View 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}` (07).
| Топик | Тип данных | Описание | Пример 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`).*

View 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

View 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

View 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

View 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

View 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

View 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));
}

View 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;
}

View 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

View 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));
}
}

View 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

View 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];
}

View 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

View 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));
}
}

View 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

View 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
View File

@@ -0,0 +1 @@
/testing

61
backend_control/README.md Normal file
View File

@@ -0,0 +1,61 @@
# Интерфейс управления
![](img/gui_interface.png)
## Область Управления
`Целевой 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)`
![](img/servo_interface.png)
## Область Управление Серво
Тут можно задать угол сервопривода и отключить сервопривод
## Облать Статус и телеметрия
Вывод информации о сервоприводе включен или выключен. Так же выведен текущий угол.

View 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
View 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -0,0 +1,2 @@
paho-mqtt
customtkinter

2
kicad/ozon/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.lck
.history

1
kicad/ozon/.history Submodule

Submodule kicad/ozon/.history added at 2a8a462227

View File

@@ -0,0 +1,2 @@
(kicad_pcb (version 20260206) (generator "pcbnew") (generator_version "10.0")
)

105
kicad/ozon/ozon.kicad_prl Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

26
specification/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Наброски будущей схемы конвейера
![](mvp_1.jpg)
## Список материалов:
В этот список необходимо доложить энкодер для двигателя!
| Название | Сумма, ₽ | Количество |
| :--- | :--- | :--- |
| Медная лента для удаления припоя / Оплетка для выпайки диаметр 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
View 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
View 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="&lt;font style=&quot;font-size: 30px;&quot;&gt;1&lt;/font&gt;" 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="&lt;font style=&quot;font-size: 30px;&quot;&gt;2&lt;/font&gt;" 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="&lt;font style=&quot;font-size: 30px;&quot;&gt;3&lt;/font&gt;" 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="Лазерный датчик расстояния.&lt;br&gt;В нашем случае он измеряет высоту объекта.&lt;br&gt;Так как камера у нас расположена перпендикулярно движению ленты. И корректно не сможет определить высоту объектов" 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View 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
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
debug_frames/
*.jpg
*.png
.pytest_cache/

View 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"]

View 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.51.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 не реагирует | одна WiFi сеть, брокер `192.168.31.225:1883`, логин `test`/`1234` |
| depth valid < 30% | USB3, высота 0.51.5 м, не чёрная лента в упор |
| серво не те | поправьте `routing.zones.*.servo` под SCADA |
| лента не едет | `motor.enabled: true`, проверьте SCADA rpm вручную |
## 9. Чеклист «всё связано»
1. [ ] `mosquitto` Up (`docker ps`)
2. [ ] ESP32 в WiFi, в 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. [ ] При объекте срабатывает нужный серво, лента крутится

View 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
View 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 молчит | одна WiFi сеть, брокер **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).

View 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())

View 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.51.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
View 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.51.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.51.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()

View 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)

View 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())

View 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
View 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.52 с: глянцевые/тёмные предметы (мышка) дают
# кратковременные выпадения 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
View 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 "$@"

View 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

View 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"]

View 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")

View 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
View 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())

View 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:
"""
Поперечные срезы. Из логов: у круга часто 12 среза ≥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)

View 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()

View 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
View 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 "$@"

View File

@@ -0,0 +1,306 @@
"""
Стабильный консенсус по кадрам.
Из логов RealSense: круг даёт sec≈0.820.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)

View 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")

View 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")

View 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, # ~1520 с помнить «уже учтён»
) -> 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