diff --git a/packages/scratch-gui/src/components/blocks/blocks.css b/packages/scratch-gui/src/components/blocks/blocks.css index 583f587f79..3aba7c3c92 100644 --- a/packages/scratch-gui/src/components/blocks/blocks.css +++ b/packages/scratch-gui/src/components/blocks/blocks.css @@ -3,97 +3,70 @@ @import "../../css/z-index.css"; .blocks { - height: 100%; + height: 100%; } -.drag-over:after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.75; - background-color: $drop-highlight; - transition: all 0.25s ease; +.drag-over::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.75; + background-color: rgba(0, 191, 255, 0.2); /* antes $drop-highlight */ + transition: all 0.25s ease; } -.blocks :global(.injectionDiv){ - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - border: 1px solid $ui-black-transparent; - border-top-right-radius: $space; - border-bottom-right-radius: $space; +.blocks .injectionDiv { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + border: 1px solid rgba(0,0,0,0.2); /* antes $ui-black-transparent */ + border-top-right-radius: 0.25rem; /* antes $space */ + border-bottom-right-radius: 0.25rem; } -[dir="rtl"] .blocks :global(.injectionDiv) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-top-left-radius: $space; - border-bottom-left-radius: $space; +[dir="rtl"] .blocks .injectionDiv { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; } -.blocks :global(.blocklyMainBackground) { - stroke: none; +.blocks .blocklyMainBackground { + stroke: none; } -.blocks :global(.blocklyToolboxDiv) { - border-right: 1px solid $ui-black-transparent; - border-bottom: 1px solid $ui-black-transparent; - box-sizing: content-box; - height: calc(100% - 3.25rem) !important; - - /* - For now, the layout cannot support scrollbars in the category menu. - The line below works for Edge, the `::-webkit-scrollbar` line - below that is for webkit browsers. It isn't possible to do the - same for Firefox, so a different solution may be needed for them. - */ - -ms-overflow-style: none; -} - -[dir="rtl"] .blocks :global(.blocklyToolboxDiv) { - border-right: none; - border-left: 1px solid $ui-black-transparent; +.blocks .blocklyToolboxDiv { + border-right: 1px solid rgba(0,0,0,0.2); + border-bottom: 1px solid rgba(0,0,0,0.2); + box-sizing: content-box; + height: calc(100% - 3.25rem) !important; + -ms-overflow-style: none; } -.blocks :global(.blocklyToolboxDiv::-webkit-scrollbar) { - display: none; +.blocks .blocklyToolboxDiv::-webkit-scrollbar { + display: none; } -.blocks :global(.blocklyFlyout) { - border-right: 1px solid $ui-black-transparent; - box-sizing: content-box; +.blocks .blocklyFlyout { + border-right: 1px solid rgba(0,0,0,0.2); + box-sizing: content-box; } -[dir="rtl"] .blocks :global(.blocklyFlyout) { - border-right: none; - border-left: 1px solid $ui-black-transparent; -} - - -.blocks :global(.blocklyBlockDragSurface) { - /* - Fix an issue where the drag surface was preventing hover events for sharing blocks. - This does not prevent user interaction on the blocks themselves. - */ - pointer-events: none; - z-index: $z-index-drag-layer; /* make blocks match gui drag layer */ +.blocks .blocklyBlockDragSurface { + pointer-events: none; + z-index: 200; /* antes $z-index-drag-layer */ } -/* - Shrink category font to fit "My Blocks" for now. - Probably will need different solutions for language support later, so - make the change here instead of in scratch-blocks. -*/ -.blocks :global(.scratchCategoryMenuItemLabel) { - font-size: 0.65rem; +.blocks .scratchCategoryMenuItemLabel { + font-size: 0.65rem; } -.blocks :global(.blocklyMinimalBody) { - min-width: auto; - min-height: auto; +.blocks .blocklyMinimalBody { + min-width: auto; + min-height: auto; } diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 171ef06cca..0de0c41a85 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -44,6 +44,8 @@ import soundsIcon from './icon--sounds.svg'; import DebugModal from '../debug-modal/debug-modal.jsx'; import {setPlatform} from '../../reducers/platform.js'; import {PLATFORM} from '../../lib/platform.js'; +import VisionPreview from '../../containers/vision-preview.jsx'; + const messages = defineMessages({ addExtension: { @@ -398,7 +400,6 @@ const GUIComponent = props => { ) : null} - { onNewBackdropClick={onNewLibraryBackdropClick} /> + diff --git a/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.css b/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.css index be106ebf8c..692582b369 100644 --- a/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.css +++ b/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.css @@ -13,18 +13,43 @@ .stage-wrapper.full-screen { position: fixed; - top: $stage-menu-height; + top: stage-menu-height; left: 0; right: 0; bottom: 0; - z-index: $z-index-stage-wrapper-overlay; - background-color: $ui-white; + z-index: z-index-stage-wrapper-overlay; + background-color: ui-white; /* spacing between stage and control bar (on the top), or between stage and window edges (on left/right/bottom) */ - padding: $stage-full-screen-stage-padding; + padding: stage-full-screen-stage-padding; /* this centers the stage */ display: flex; flex-direction: column; align-items: center; } + +.vision-viewer { + margin-top: 10px; + height: 240px; /* ajusta según necesites */ + background: #fff; +} + +.visionFullScreen { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: #000; /* Fondo negro */ +} + +.visionFullScreen canvas, +.visionFullScreen img, +.visionFullScreen video { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 8px; +} + diff --git a/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.jsx b/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.jsx index 109f0981a2..15cee5e0f3 100644 --- a/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.jsx +++ b/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.jsx @@ -4,25 +4,28 @@ import classNames from 'classnames'; import VM from '@scratch/scratch-vm'; import Box from '../box/box.jsx'; -import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; -import StageHeader from '../../containers/stage-header.jsx'; -import Stage from '../../containers/stage.jsx'; import Loader from '../loader/loader.jsx'; - +import VisionViewer from '../../lib/vision-viewer'; import styles from './stage-wrapper.css'; -const StageWrapperComponent = function (props) { - const { - isFullScreen, - isRtl, - isRendererSupported, - loading, - manuallySaveThumbnails, - onUpdateProjectThumbnail, - stageSize, - vm - } = props; - +/** + * StageWrapperComponent + * + * Componente que reemplaza el escenario estándar de Scratch por la vista del Vision Kit. + * + * @param {object} props - Propiedades del componente. + * @param {boolean} props.isFullScreen - Indica si está en modo pantalla completa. + * @param {boolean} props.isRtl - Define la orientación del texto (derecha a izquierda). + * @param {boolean} props.loading - Muestra el loader si está cargando. + * @param {VM} props.vm - Instancia del Virtual Machine (VM) de Scratch. + * @returns {JSX.Element} El componente visual del escenario Vision Kit. + */ +const StageWrapperComponent = function ({ + isFullScreen, + isRtl, + loading, + vm +}) { return ( - - - - - { - isRendererSupported ? - : - null - } - - {loading ? ( - - ) : null} + {/* 🔹 Reemplazamos el escenario normal por la vista de Vision Kit */} +
+ +
+ + {loading ? : null}
); }; StageWrapperComponent.propTypes = { isFullScreen: PropTypes.bool, - isRendererSupported: PropTypes.bool.isRequired, isRtl: PropTypes.bool.isRequired, loading: PropTypes.bool, - manuallySaveThumbnails: PropTypes.bool, - onUpdateProjectThumbnail: PropTypes.func, - stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, vm: PropTypes.instanceOf(VM).isRequired }; diff --git a/packages/scratch-gui/src/components/vision-preview/vision-preview.jsx b/packages/scratch-gui/src/components/vision-preview/vision-preview.jsx new file mode 100644 index 0000000000..4330736936 --- /dev/null +++ b/packages/scratch-gui/src/components/vision-preview/vision-preview.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class VisionPreview extends React.Component{ + constructor (props) { + super(props); + this.state = {dataURL: null}; + } + componentDidMount (){ + const {vm} = this.props; + this._handler = dataURL => this.setState({dataURL}); + vm.on('VISION_IMAGE', this._handler); + } + componentWillUnmount (){ + const {vm} = this.props; + if (this._handler) vm.off('VISION_IMAGE', this._handler); + } + render (){ + return ( +
+
{'Vista previa (Vision Kit)'}
+ {this.state.dataURL ? ( + preview + ) : ( +
+ {'Aún no hay imagen procesada…'} +
+ )} +
+ ); + } +} +VisionPreview.propTypes = {vm: PropTypes.object.isRequired}; +export default VisionPreview; diff --git a/packages/scratch-gui/src/containers/vision-preview.jsx b/packages/scratch-gui/src/containers/vision-preview.jsx new file mode 100644 index 0000000000..a797e21f0b --- /dev/null +++ b/packages/scratch-gui/src/containers/vision-preview.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import VisionPreview from '../components/vision-preview/vision-preview'; + +/** + * Contenedor del componente VisionPreview. + * Renderiza la vista previa del Vision Kit y recibe la instancia de VM como prop. + * + * @component + * @param {object} props - Propiedades del componente + * @param {object} props.vm - Instancia de scratch-vm que maneja la lógica del proyecto + * @returns {JSX.Element} El componente VisionPreview con la prop vm + */ +const VisionPreviewContainer = ({vm}) => ( + +); + +VisionPreviewContainer.propTypes = { + vm: PropTypes.object.isRequired +}; + +export default VisionPreviewContainer; diff --git a/packages/scratch-gui/src/lib/libraries/extensions/index.jsx b/packages/scratch-gui/src/lib/libraries/extensions/index.jsx index b1163e466c..f894e2ab06 100644 --- a/packages/scratch-gui/src/lib/libraries/extensions/index.jsx +++ b/packages/scratch-gui/src/lib/libraries/extensions/index.jsx @@ -1,393 +1,66 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; - -import musicIconURL from './music/music.png'; -import musicInsetIconURL from './music/music-small.svg'; - -import penIconURL from './pen/pen.png'; -import penInsetIconURL from './pen/pen-small.svg'; - -import videoSensingIconURL from './videoSensing/video-sensing.png'; -import videoSensingInsetIconURL from './videoSensing/video-sensing-small.svg'; - -import text2speechIconURL from './text2speech/text2speech.png'; -import text2speechInsetIconURL from './text2speech/text2speech-small.svg'; - -import translateIconURL from './translate/translate.png'; -import translateInsetIconURL from './translate/translate-small.png'; - -import makeymakeyIconURL from './makeymakey/makeymakey.png'; -import makeymakeyInsetIconURL from './makeymakey/makeymakey-small.svg'; - -import microbitIconURL from './microbit/microbit.png'; -import microbitInsetIconURL from './microbit/microbit-small.svg'; -import microbitConnectionIconURL from './microbit/microbit-illustration.svg'; -import microbitConnectionSmallIconURL from './microbit/microbit-small.svg'; - -import ev3IconURL from './ev3/ev3.png'; -import ev3InsetIconURL from './ev3/ev3-small.svg'; -import ev3ConnectionIconURL from './ev3/ev3-hub-illustration.svg'; -import ev3ConnectionSmallIconURL from './ev3/ev3-small.svg'; - -import wedo2IconURL from './wedo2/wedo.png'; // TODO: Rename file names to match variable/prop names? -import wedo2InsetIconURL from './wedo2/wedo-small.svg'; -import wedo2ConnectionIconURL from './wedo2/wedo-illustration.svg'; -import wedo2ConnectionSmallIconURL from './wedo2/wedo-small.svg'; -import wedo2ConnectionTipIconURL from './wedo2/wedo-button-illustration.svg'; - -import boostIconURL from './boost/boost.png'; -import boostInsetIconURL from './boost/boost-small.svg'; -import boostConnectionIconURL from './boost/boost-illustration.svg'; -import boostConnectionSmallIconURL from './boost/boost-small.svg'; -import boostConnectionTipIconURL from './boost/boost-button-illustration.svg'; - -import gdxforIconURL from './gdxfor/gdxfor.png'; -import gdxforInsetIconURL from './gdxfor/gdxfor-small.svg'; -import gdxforConnectionIconURL from './gdxfor/gdxfor-illustration.svg'; -import gdxforConnectionSmallIconURL from './gdxfor/gdxfor-small.svg'; +import visionIconURL from './vision/vision.png'; export default [ { - name: ( - - ), - extensionId: 'music', - iconURL: musicIconURL, - insetIconURL: musicInsetIconURL, - description: ( - - ), - featured: true - }, - { - name: ( - - ), - extensionId: 'pen', - iconURL: penIconURL, - insetIconURL: penInsetIconURL, - description: ( - - ), - featured: true - }, - { - name: ( - - ), - extensionId: 'videoSensing', - iconURL: videoSensingIconURL, - insetIconURL: videoSensingInsetIconURL, - description: ( - - ), - featured: true - }, - { - name: ( - - ), - extensionId: 'text2speech', - collaborator: 'Amazon Web Services', - iconURL: text2speechIconURL, - insetIconURL: text2speechInsetIconURL, - description: ( - - ), - featured: true, - internetConnectionRequired: true - }, - { - name: ( - - ), - extensionId: 'translate', - collaborator: 'Google', - iconURL: translateIconURL, - insetIconURL: translateInsetIconURL, - description: ( - - ), - featured: true, - internetConnectionRequired: true - }, - { - name: 'Makey Makey', - extensionId: 'makeymakey', - collaborator: 'JoyLabz', - iconURL: makeymakeyIconURL, - insetIconURL: makeymakeyInsetIconURL, + name: 'Vision Acciones', + extensionId: 'visionactions', + iconURL: visionIconURL, + insetIconURL: visionIconURL, description: ( - ), - featured: true - }, - { - name: 'micro:bit', - extensionId: 'microbit', - collaborator: 'micro:bit', - iconURL: microbitIconURL, - insetIconURL: microbitInsetIconURL, - description: ( - ), featured: true, - disabled: false, - bluetoothRequired: true, - internetConnectionRequired: true, - launchPeripheralConnectionFlow: true, - useAutoScan: false, - connectionIconURL: microbitConnectionIconURL, - connectionSmallIconURL: microbitConnectionSmallIconURL, - prescanMessage: ( - - ), - scanBeginMessage: ( - - ), - connectingMessage: ( - - ), - helpLink: 'https://scratch.mit.edu/microbit' - }, - { - name: 'Go Direct Force & Acceleration', - extensionId: 'gdxfor', - collaborator: 'Vernier', - iconURL: gdxforIconURL, - insetIconURL: gdxforInsetIconURL, - description: ( - - ), - featured: true, - disabled: false, - bluetoothRequired: true, - internetConnectionRequired: true, - launchPeripheralConnectionFlow: true, - useAutoScan: false, - connectionIconURL: gdxforConnectionIconURL, - connectionSmallIconURL: gdxforConnectionSmallIconURL, - prescanMessage: ( - - ), - scanBeginMessage: ( - - ), - connectingMessage: ( - - ), - helpLink: 'https://scratch.mit.edu/vernier' + collaborator: 'OpenCV + Scratch EDU', + tags: ['visión', 'imagen', 'acciones'] }, { - name: 'LEGO MINDSTORMS EV3', - extensionId: 'ev3', - collaborator: 'LEGO', - iconURL: ev3IconURL, - insetIconURL: ev3InsetIconURL, + name: 'Vision Básico', + extensionId: 'visionbasic', + iconURL: visionIconURL, + insetIconURL: visionIconURL, description: ( ), featured: true, - disabled: false, - bluetoothRequired: true, - internetConnectionRequired: true, - launchPeripheralConnectionFlow: true, - useAutoScan: false, - connectionIconURL: ev3ConnectionIconURL, - connectionSmallIconURL: ev3ConnectionSmallIconURL, - prescanMessage: ( - - ), - scanBeginMessage: ( - - ), - connectingMessage: ( - - ), - helpLink: 'https://scratch.mit.edu/ev3' + collaborator: 'OpenCV + Scratch EDU', + tags: ['visión', 'imagen', 'básico'] }, { - name: 'LEGO BOOST', - extensionId: 'boost', - collaborator: 'LEGO', - iconURL: boostIconURL, - insetIconURL: boostInsetIconURL, + name: 'Vision Intermedio', + extensionId: 'visionintermediate', + iconURL: visionIconURL, + insetIconURL: visionIconURL, description: ( ), featured: true, - disabled: false, - bluetoothRequired: true, - internetConnectionRequired: true, - launchPeripheralConnectionFlow: true, - useAutoScan: true, - connectionIconURL: boostConnectionIconURL, - connectionSmallIconURL: boostConnectionSmallIconURL, - connectionTipIconURL: boostConnectionTipIconURL, - prescanMessage: ( - - ), - scanBeginMessage: ( - - ), - connectingMessage: ( - - ), - helpLink: 'https://scratch.mit.edu/boost' + collaborator: 'OpenCV + Scratch EDU', + tags: ['visión', 'imagen', 'intermedio'] }, { - name: 'LEGO Education WeDo 2.0', - extensionId: 'wedo2', - collaborator: 'LEGO', - iconURL: wedo2IconURL, - insetIconURL: wedo2InsetIconURL, + name: 'Vision Avanzado', + extensionId: 'visionadvanced', + iconURL: visionIconURL, + insetIconURL: visionIconURL, description: ( ), featured: true, - disabled: false, - bluetoothRequired: true, - internetConnectionRequired: true, - launchPeripheralConnectionFlow: true, - useAutoScan: true, - connectionIconURL: wedo2ConnectionIconURL, - connectionSmallIconURL: wedo2ConnectionSmallIconURL, - connectionTipIconURL: wedo2ConnectionTipIconURL, - prescanMessage: ( - - ), - scanBeginMessage: ( - - ), - connectingMessage: ( - - ), - helpLink: 'https://scratch.mit.edu/wedo' + collaborator: 'OpenCV + Scratch EDU', + tags: ['visión', 'imagen', 'avanzado'] } ]; diff --git a/packages/scratch-gui/src/lib/libraries/extensions/vision-extensions.jsx b/packages/scratch-gui/src/lib/libraries/extensions/vision-extensions.jsx new file mode 100644 index 0000000000..f894e2ab06 --- /dev/null +++ b/packages/scratch-gui/src/lib/libraries/extensions/vision-extensions.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import visionIconURL from './vision/vision.png'; + +export default [ + { + name: 'Vision Acciones', + extensionId: 'visionactions', + iconURL: visionIconURL, + insetIconURL: visionIconURL, + description: ( + + ), + featured: true, + collaborator: 'OpenCV + Scratch EDU', + tags: ['visión', 'imagen', 'acciones'] + }, + { + name: 'Vision Básico', + extensionId: 'visionbasic', + iconURL: visionIconURL, + insetIconURL: visionIconURL, + description: ( + + ), + featured: true, + collaborator: 'OpenCV + Scratch EDU', + tags: ['visión', 'imagen', 'básico'] + }, + { + name: 'Vision Intermedio', + extensionId: 'visionintermediate', + iconURL: visionIconURL, + insetIconURL: visionIconURL, + description: ( + + ), + featured: true, + collaborator: 'OpenCV + Scratch EDU', + tags: ['visión', 'imagen', 'intermedio'] + }, + { + name: 'Vision Avanzado', + extensionId: 'visionadvanced', + iconURL: visionIconURL, + insetIconURL: visionIconURL, + description: ( + + ), + featured: true, + collaborator: 'OpenCV + Scratch EDU', + tags: ['visión', 'imagen', 'avanzado'] + } +]; diff --git a/packages/scratch-gui/src/lib/libraries/extensions/vision/vision.png b/packages/scratch-gui/src/lib/libraries/extensions/vision/vision.png new file mode 100644 index 0000000000..1f3f297aa1 Binary files /dev/null and b/packages/scratch-gui/src/lib/libraries/extensions/vision/vision.png differ diff --git a/packages/scratch-gui/src/lib/make-toolbox-xml.js b/packages/scratch-gui/src/lib/make-toolbox-xml.js index 8d356e442d..92cb7feb20 100644 --- a/packages/scratch-gui/src/lib/make-toolbox-xml.js +++ b/packages/scratch-gui/src/lib/make-toolbox-xml.js @@ -1,811 +1,120 @@ -import ScratchBlocks from 'scratch-blocks'; +/* eslint-disable quote-props */ +/* global ScratchBlocks */ import {defaultColors} from './themes'; -const categorySeparator = ''; - -const blockSeparator = ''; // At default scale, about 28px - -/* eslint-disable no-unused-vars */ -const motion = function (isInitialSetup, isStage, targetId, colors) { - const stageSelected = ScratchBlocks.ScratchMsgs.translate( - 'MOTION_STAGE_SELECTED', - 'Stage selected: no motion blocks' - ); - // Note: the category's secondaryColour matches up with the blocks' tertiary color, both used for border color. - return ` - - ${isStage ? ` - - ` : ` - - - - 10 - - - - - - - 15 - - - - - - - 15 - - - - ${blockSeparator} - - - - - - - - - - 0 - - - - - 0 - - - - - - - 1 - - - - - - - - - - - 1 - - - - - 0 - - - - - 0 - - - - ${blockSeparator} - - - - 90 - - - - - - - - - - ${blockSeparator} - - - - 10 - - - - - - - 0 - - - - - - - 10 - - - - - - - 0 - - - - ${blockSeparator} - - ${blockSeparator} - - ${blockSeparator} - - - `} - ${categorySeparator} - - `; -}; - -const xmlEscape = function (unsafe) { - return unsafe.replace(/[<>&'"]/g, c => { - switch (c) { - case '<': return '<'; - case '>': return '>'; - case '&': return '&'; - case '\'': return '''; - case '"': return '"'; - } - }); -}; - -const looks = function (isInitialSetup, isStage, targetId, costumeName, backdropName, colors) { - const hello = ScratchBlocks.ScratchMsgs.translate('LOOKS_HELLO', 'Hello!'); - const hmm = ScratchBlocks.ScratchMsgs.translate('LOOKS_HMM', 'Hmm...'); - // Note: the category's secondaryColour matches up with the blocks' tertiary color, both used for border color. - return ` - - ${isStage ? '' : ` - - - - ${hello} - - - - - 2 - - - - - - - ${hello} - - - - - - - ${hmm} - - - - - 2 - - - - - - - ${hmm} - - - - ${blockSeparator} - `} - ${isStage ? ` - - - - ${backdropName} - - - - - - - ${backdropName} - - - - - ` : ` - - - - ${costumeName} - - - - - - - - ${backdropName} - - - - - ${blockSeparator} - - - - 10 - - - - - - - 100 - - - - `} - ${blockSeparator} - - - - 25 - - - - - - - 0 - - - - - ${blockSeparator} - ${isStage ? '' : ` - - - ${blockSeparator} - - - - - 1 - - - - `} - ${isStage ? ` - - ` : ` - - - - `} - ${categorySeparator} - - `; -}; - -const sound = function (isInitialSetup, isStage, targetId, soundName, colors) { - // Note: the category's secondaryColour matches up with the blocks' tertiary color, both used for border color. - return ` - - - - - ${soundName} - - - - - - - ${soundName} - - - - - ${blockSeparator} - - - - 10 - - - - - - - 100 - - - - - ${blockSeparator} - - - - -10 - - - - - - - 100 - - - - - ${categorySeparator} - - `; -}; - -const events = function (isInitialSetup, isStage, targetId, colors) { - // Note: the category's secondaryColour matches up with the blocks' tertiary color, both used for border color. - return ` - - - - - ${isStage ? ` - - ` : ` - - `} - - - ${blockSeparator} - - - - 10 - - - - ${blockSeparator} - - - - - - - - - - - - - ${categorySeparator} - - `; -}; - -const control = function (isInitialSetup, isStage, targetId, colors) { - // Note: the category's secondaryColour matches up with the blocks' tertiary color, both used for border color. - return ` - - - - - 1 - - - - ${blockSeparator} - - - - 10 - - - - - ${blockSeparator} - - - - - ${blockSeparator} - - ${blockSeparator} - ${isStage ? ` - - - - - - ` : ` - - - - - - - - `} - ${categorySeparator} - - `; -}; - -const sensing = function (isInitialSetup, isStage, targetId, colors) { - const name = ScratchBlocks.ScratchMsgs.translate('SENSING_ASK_TEXT', 'What\'s your name?'); - // Note: the category's secondaryColour matches up with the blocks' tertiary color, both used for border color. - return ` - - ${isStage ? '' : ` - - - - - - - - - - - - - - - - - - - - - - - - ${blockSeparator} - `} - ${isInitialSetup ? '' : ` - - - - ${name} - - - - `} - - ${blockSeparator} - - - - - - - - - ${isStage ? '' : ` - ${blockSeparator} - ''+ - ${blockSeparator} - `} - ${blockSeparator} - - ${blockSeparator} - - - ${blockSeparator} - - - - - - ${blockSeparator} - - - ${blockSeparator} - - ${categorySeparator} - - `; -}; - -const operators = function (isInitialSetup, isStage, targetId, colors) { - const apple = ScratchBlocks.ScratchMsgs.translate('OPERATORS_JOIN_APPLE', 'apple'); - const banana = ScratchBlocks.ScratchMsgs.translate('OPERATORS_JOIN_BANANA', 'banana'); - const letter = ScratchBlocks.ScratchMsgs.translate('OPERATORS_LETTEROF_APPLE', 'a'); - // Note: the category's secondaryColour matches up with the blocks' tertiary color, both used for border color. - return ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ${blockSeparator} - - - - 1 - - - - - 10 - - - - ${blockSeparator} - - - - - - - - - 50 - - - - - - - - - - - - 50 - - - - - - - - - - - - 50 - - - - ${blockSeparator} - - - - ${blockSeparator} - ${isInitialSetup ? '' : ` - - - - ${apple} - - - - - ${banana} - - - - - - - 1 - - - - - ${apple} - - - - - - - ${apple} - - - - - - - ${apple} - - - - - ${letter} - - - - `} - ${blockSeparator} - - - - - - - - - - - - - - - - - - - - ${blockSeparator} - - - - - - - - ${categorySeparator} - - `; -}; - -const variables = function (isInitialSetup, isStage, targetId, colors) { - // Note: the category's secondaryColour matches up with the blocks' tertiary color, both used for border color. - return ` - - - `; -}; - -const myBlocks = function (isInitialSetup, isStage, targetId, colors) { - // Note: the category's secondaryColour matches up with the blocks' tertiary color, both used for border color. - return ` - - - `; -}; -/* eslint-enable no-unused-vars */ - const xmlOpen = ''; const xmlClose = ''; /** - * @param {!boolean} isInitialSetup - Whether the toolbox is for initial setup. If the mode is "initial setup", - * blocks with localized default parameters (e.g. ask and wait) should not be loaded. (LLK/scratch-gui#5445) - * @param {?boolean} isStage - Whether the toolbox is for a stage-type target. This is always set to true - * when isInitialSetup is true. - * @param {?string} targetId - The current editing target - * @param {?Array.} categoriesXML - optional array of `{id,xml}` for categories. This can include both core - * and other extensions: core extensions will be placed in the normal Scratch order; others will go at the bottom. - * @property {string} id - the extension / category ID. - * @property {string} xml - the `...` XML for this extension / category. - * @param {?string} costumeName - The name of the default selected costume dropdown. - * @param {?string} backdropName - The name of the default selected backdrop dropdown. - * @param {?string} soundName - The name of the default selected sound dropdown. - * @param {?object} colors - The colors for the theme. - * @returns {string} - a ScratchBlocks-style XML document for the contents of the toolbox. + * 🧱 Genera dinámicamente el XML del Toolbox para los bloques Vision. + * Escanea todos los bloques de la VM que comiencen con "vision". */ -const makeToolboxXML = function (isInitialSetup, isStage = true, targetId, categoriesXML = [], - costumeName = '', backdropName = '', soundName = '', colors = defaultColors) { - isStage = isInitialSetup || isStage; - const gap = [categorySeparator]; - costumeName = xmlEscape(costumeName); - backdropName = xmlEscape(backdropName); - soundName = xmlEscape(soundName); +const makeToolboxXML = function () { + let visionBlocks = []; - categoriesXML = categoriesXML.slice(); - const moveCategory = categoryId => { - const index = categoriesXML.findIndex(categoryInfo => categoryInfo.id === categoryId); - if (index >= 0) { - // remove the category from categoriesXML and return its XML - const [categoryInfo] = categoriesXML.splice(index, 1); - return categoryInfo.xml; + try { + const vm = (window.Scratch && window.Scratch.vm) || window.vm; + if (vm && vm.runtime && vm.runtime._primitives) { + const allBlocks = Object.keys(vm.runtime._primitives); + visionBlocks = allBlocks.filter(id => id.startsWith('vision')); } - // return `undefined` - }; - const motionXML = moveCategory('motion') || motion(isInitialSetup, isStage, targetId, colors.motion); - const looksXML = moveCategory('looks') || - looks(isInitialSetup, isStage, targetId, costumeName, backdropName, colors.looks); - const soundXML = moveCategory('sound') || sound(isInitialSetup, isStage, targetId, soundName, colors.sounds); - const eventsXML = moveCategory('event') || events(isInitialSetup, isStage, targetId, colors.event); - const controlXML = moveCategory('control') || control(isInitialSetup, isStage, targetId, colors.control); - const sensingXML = moveCategory('sensing') || sensing(isInitialSetup, isStage, targetId, colors.sensing); - const operatorsXML = moveCategory('operators') || operators(isInitialSetup, isStage, targetId, colors.operators); - const variablesXML = moveCategory('data') || variables(isInitialSetup, isStage, targetId, colors.data); - const myBlocksXML = moveCategory('procedures') || myBlocks(isInitialSetup, isStage, targetId, colors.more); - - const everything = [ - xmlOpen, - motionXML, gap, - looksXML, gap, - soundXML, gap, - eventsXML, gap, - controlXML, gap, - sensingXML, gap, - operatorsXML, gap, - variablesXML, gap, - myBlocksXML - ]; + } catch (err) { + console.warn('[VisionKit] No se pudo acceder al VM:', err); + } - for (const extensionCategory of categoriesXML) { - everything.push(gap, extensionCategory.xml); + if (!visionBlocks.length) { + console.warn('[VisionKit] No se encontraron bloques Vision registrados.'); + setTimeout(() => window.dispatchEvent(new Event('refreshToolboxVision')), 1500); } - everything.push(xmlClose); - return everything.join('\n'); + const blockList = visionBlocks.length ? visionBlocks.map(id => ``).join('\n') : ''; + + const visionCategoryXML = ` + + ${blockList} + + `; + + return [xmlOpen, visionCategoryXML, xmlClose].join('\n'); }; +// ============================================================ +// 🔧 REGISTRO AUTOMÁTICO DE BLOQUES (anti-bloques rojos) +// ============================================================ +if (typeof window !== 'undefined') { + const defineAutoBlocks = () => { + const vm = (window.Scratch && window.Scratch.vm) || window.vm; + if (!vm || !vm.runtime || !vm.runtime._primitives || !window.ScratchBlocks) return; + + const blockIds = Object.keys(vm.runtime._primitives).filter(id => + id.startsWith('vision') + ); + + blockIds.forEach(id => { + if (!ScratchBlocks.Blocks[id]) { + ScratchBlocks.Blocks[id] = { + init () { + const label = id + .replace(/^vision_?/, '') + .replace(/_/g, ' ') + .trim(); + + this.jsonInit({ + type: id, + message0: label || id, + previousStatement: null, + nextStatement: null, + colour: '#0E7490', + tooltip: id, + helpUrl: '' + }); + } + }; + } + }); + + console.log(`[✅ VisionKit] ${blockIds.length} bloques visuales listos.`); + }; + + // ⏳ Esperar a que VM y Blockly estén listas + const waitForBlocks = setInterval(() => { + const ready = + window.ScratchBlocks && + ((window.Scratch && window.Scratch.vm) || window.vm); + if (ready) { + clearInterval(waitForBlocks); + defineAutoBlocks(); + } + }, 800); + + // 🔁 Refrescar toolbox Vision dinámicamente + window.addEventListener('refreshToolboxVision', () => { + console.log('[VisionKit] 🔁 Refrescando toolbox Vision...'); + const xml = makeToolboxXML(); + + // Buscar el store Redux (3 posibles ubicaciones) + const store = + window.store || + (window.ScratchGUI && window.ScratchGUI.store) || + (window.__REDUX_DEVTOOLS_EXTENSION__ && + window.__REDUX_DEVTOOLS_EXTENSION__.store); + + if (store && store.dispatch) { + store.dispatch({ + type: 'scratch-gui/toolbox/UPDATE_TOOLBOX', + toolboxXML: xml + }); + console.log('[VisionKit] 🧱 Toolbox Vision actualizado dinámicamente.'); + } else { + console.warn('[VisionKit] ⚠️ No se encontró store Redux para actualizar toolbox.'); + } + }); +} + export default makeToolboxXML; diff --git a/packages/scratch-gui/src/lib/vision-actions.json b/packages/scratch-gui/src/lib/vision-actions.json new file mode 100644 index 0000000000..bea1aaf534 --- /dev/null +++ b/packages/scratch-gui/src/lib/vision-actions.json @@ -0,0 +1,9 @@ +{ + "name": "Vision Acciones", + "extensionId": "visionactions", + "iconURL": "vision/vision-actions.png", + "insetIconURL": "vision/vision-actions.png", + "description": "Carga, visualización y exportación de imágenes.", + "featured": true, + "collaborator": "OpenCV + Scratch EDU" +} diff --git a/packages/scratch-gui/src/lib/vision-advanced.json b/packages/scratch-gui/src/lib/vision-advanced.json new file mode 100644 index 0000000000..9902488bef --- /dev/null +++ b/packages/scratch-gui/src/lib/vision-advanced.json @@ -0,0 +1,9 @@ +{ + "name": "Vision Avanzado", + "extensionId": "visionadvanced", + "iconURL": "vision/vision-advanced.png", + "insetIconURL": "vision/vision-advanced.png", + "description": "Segmentación y extracción de características.", + "featured": true, + "collaborator": "OpenCV + Scratch EDU" +} diff --git a/packages/scratch-gui/src/lib/vision-autoregister.js b/packages/scratch-gui/src/lib/vision-autoregister.js new file mode 100644 index 0000000000..26934258ba --- /dev/null +++ b/packages/scratch-gui/src/lib/vision-autoregister.js @@ -0,0 +1,43 @@ +/* eslint-disable quote-props */ +/* global ScratchBlocks */ + +/** + * Registro automático de bloques Vision en ScratchBlocks. + * Evita los “bloques rojos” y garantiza que se dibujen correctamente. + */ +export default function registerVisionBlocks () { + const waitForEnvironment = setInterval(() => { + // Verificamos que el entorno esté completamente cargado + if (!window.ScratchBlocks || !window.Scratch?.vm?.runtime?._primitives) return; + + clearInterval(waitForEnvironment); + + const primitives = window.Scratch.vm.runtime._primitives; + const visionIds = Object.keys(primitives).filter(k => k.startsWith('vision')); + + visionIds.forEach(id => { + if (!ScratchBlocks.Blocks[id]) { + ScratchBlocks.Blocks[id] = { + init: function () { + const label = id + .replace(/vision[a-z]*/, '') + .replace(/_/g, ' ') + .trim(); + + this.jsonInit({ + type: id, + message0: label || id, + previousStatement: null, + nextStatement: null, + colour: '#0E7490', + tooltip: id, + helpUrl: '' + }); + } + }; + } + }); + + console.log(`[VisionKit] ✅ ${visionIds.length} bloques registrados en ScratchBlocks`); + }, 1000); +} diff --git a/packages/scratch-gui/src/lib/vision-basic.json b/packages/scratch-gui/src/lib/vision-basic.json new file mode 100644 index 0000000000..fb740c6fbb --- /dev/null +++ b/packages/scratch-gui/src/lib/vision-basic.json @@ -0,0 +1,9 @@ +{ + "name": "Vision Básico", + "extensionId": "visionbasic", + "iconURL": "vision/vision-basic.png", + "insetIconURL": "vision/vision-basic.png", + "description": "Filtros de color y operaciones simples.", + "featured": true, + "collaborator": "OpenCV + Scratch EDU" +} diff --git a/packages/scratch-gui/src/lib/vision-decorator.js b/packages/scratch-gui/src/lib/vision-decorator.js new file mode 100644 index 0000000000..9fff9128eb --- /dev/null +++ b/packages/scratch-gui/src/lib/vision-decorator.js @@ -0,0 +1,72 @@ +/** + * 🎨 VisionKit Decorator + * Registra las extensiones Vision y actualiza el toolbox dinámicamente. + */ + +const decorateVisionToolbox = function (vm, gui) { + console.log('🟢 [VisionKit] Decorador iniciado, esperando VM...'); + const waitForVM = setInterval(() => { + if (window.vm && window.vm.runtime && window.vm.extensionManager) { + clearInterval(waitForVM); + console.log('⚙️ [VisionKit] VM detectada, registrando extensiones...'); + registerExtensions(window.vm, gui); + } + }, 800); +}; + +/** + * 🔧 Registra las extensiones Vision dentro de la VM y actualiza la GUI. + */ + +const registerExtensions = function (vm, gui) { + try { + const modules = { + visionactions: require('scratch-vm/src/extensions/vision-actions'), + visionbasic: require('scratch-vm/src/extensions/vision-basic'), + visionintermediate: require('scratch-vm/src/extensions/vision-intermediate'), + visionadvanced: require('scratch-vm/src/extensions/vision-advanced') + }; + + // 🔹 Registrar extensiones si no lo están aún + Object.entries(modules).forEach(([id, mod]) => { + if (!vm.extensionManager.isExtensionLoaded(id)) { + vm.extensionManager._registerInternalExtension(mod); + console.log(`🧩 [VisionKit] Registrada extensión interna: ${id}`); + } + }); + + // 🔹 Esperar hasta que existan los bloques + const waitBlocks = setInterval(() => { + const primitives = Object.keys(vm.runtime._primitives || {}).filter(k => + k.includes('vision') + ); + if (primitives.length > 0) { + clearInterval(waitBlocks); + console.log(`🎨 [VisionKit] Primitivos Vision detectados: ${primitives.length}`); + + // 🔁 Actualizar toolbox + if (gui?.props?.vm?.extensionManager) { + gui.props.vm.extensionManager.refreshBlocks(); + console.log('🧱 [VisionKit] refreshBlocks ejecutado correctamente.'); + } + + if (gui?.props?.updateToolbox) { + gui.props.updateToolbox(); + console.log('🎨 [VisionKit] Toolbox actualizado desde VisionKit.'); + } else { + vm.runtime.emit('EXTENSIONS_UPDATED'); + console.log('🎨 [VisionKit] Toolbox actualizado (evento fallback).'); + } + + // 🚀 Disparar evento para actualizar XML dinámico + window.dispatchEvent(new Event('refreshToolboxVision')); + } + }, 1000); + + console.log('✅ [VisionKit] Todas las extensiones Vision registradas manualmente.'); + } catch (err) { + console.error('❌ [VisionKit] Error al registrar extensiones Vision:', err); + } +}; + +export default decorateVisionToolbox; diff --git a/packages/scratch-gui/src/lib/vision-intermediate.json b/packages/scratch-gui/src/lib/vision-intermediate.json new file mode 100644 index 0000000000..0e59b1c137 --- /dev/null +++ b/packages/scratch-gui/src/lib/vision-intermediate.json @@ -0,0 +1,9 @@ +{ + "name": "Vision Intermedio", + "extensionId": "visionintermediate", + "iconURL": "vision/vision-intermediate.png", + "insetIconURL": "vision/vision-intermediate.png", + "description": "Detección de bordes y transformaciones geométricas.", + "featured": true, + "collaborator": "OpenCV + Scratch EDU" +} diff --git a/packages/scratch-gui/src/lib/vision-register.js b/packages/scratch-gui/src/lib/vision-register.js new file mode 100644 index 0000000000..86878ba532 --- /dev/null +++ b/packages/scratch-gui/src/lib/vision-register.js @@ -0,0 +1,35 @@ +/** + * Registro manual de extensiones Vision en la VM. + * + * @param {object} vm - Instancia de la máquina virtual de Scratch (Scratch VM) + */ +export default function registerVisionExtensions (vm) { + try { + // ✅ Rutas corregidas (sube 5 niveles desde scratch-gui/src/lib) + const modules = { + visionactions: require('scratch-vm/src/extensions/vision-actions'), + visionbasic: require('scratch-vm/src/extensions/vision-basic'), + visionintermediate: require('scratch-vm/src/extensions/vision-intermediate'), + visionadvanced: require('scratch-vm/src/extensions/vision-advanced') + }; + + Object.entries(modules).forEach(([id, factory]) => { + try { + const extensionInstance = factory(vm.runtime); + vm.extensionManager._loadedExtensions[id] = extensionInstance; + + const primitives = extensionInstance.getPrimitives(); + Object.assign(vm.runtime._primitives, primitives); + + console.log(`🧩 [VisionKit] Registrada extensión interna: + ${id} (${Object.keys(primitives).length} bloques).`); + } catch (err) { + console.warn(`⚠️ [VisionKit] Error registrando extensión ${id}:`, err); + } + }); + + console.log('✅ [VisionKit] Todas las extensiones Vision registradas manualmente.'); + } catch (err) { + console.error('❌ [VisionKit] Falló el registro manual de extensiones:', err); + } +} diff --git a/packages/scratch-gui/src/lib/vision-stage-injector.jsx b/packages/scratch-gui/src/lib/vision-stage-injector.jsx new file mode 100644 index 0000000000..36e987434d --- /dev/null +++ b/packages/scratch-gui/src/lib/vision-stage-injector.jsx @@ -0,0 +1,42 @@ +import {useEffect} from 'react'; +import PropTypes from 'prop-types'; +import VM from '@scratch/scratch-vm'; + + +const VisionStageInjector = ({vm}) => { + useEffect(() => { + if (!vm || !vm.runtime) return; + + const handler = async dataUrl => { + try { + // Pasamos de dataURL -> Blob -> File (lo que espera el VM) + const res = await fetch(dataUrl); + const blob = await res.blob(); + const file = new File([blob], 'vision-result.png', {type: blob.type || 'image/png'}); + + // Sube el fondo y (normalmente) lo activa como backdrop actual + await vm.addBackdropFromFile(file); + + // Nota: + // vm.addBackdropFromFile suele cambiar al nuevo fondo automáticamente. + // Si tu build no lo hace, dime y te paso la línea para forzar el cambio. + } catch (e) { + // No rompas la UI si algo falla; solo log. + // eslint-disable-next-line no-console + console.error('VisionStageInjector error:', e); + } + }; + + vm.runtime.on('VISION_IMAGE', handler); + return () => vm.runtime.off('VISION_IMAGE', handler); + }, [vm]); + + // No renderiza nada visible + return null; +}; + +VisionStageInjector.propTypes = { + vm: PropTypes.instanceOf(VM).isRequired +}; + +export default VisionStageInjector; diff --git a/packages/scratch-gui/src/lib/vision-viewer.jsx b/packages/scratch-gui/src/lib/vision-viewer.jsx new file mode 100644 index 0000000000..7bf43d5668 --- /dev/null +++ b/packages/scratch-gui/src/lib/vision-viewer.jsx @@ -0,0 +1,53 @@ +import React, {useEffect, useRef} from 'react'; +import PropTypes from 'prop-types'; + +/** + * VisionViewer - canvas que escucha el evento "VISION_IMAGE" + * emitido desde la extensión Vision y lo renderiza. + * + * @component + * @param {object} props - Las propiedades del componente. + * @param {object} props.runtime - Instancia del runtime de Scratch VM. + * @returns {JSX.Element} Un canvas que muestra la imagen procesada. + */ +const VisionViewer = ({runtime}) => { + const canvasRef = useRef(null); + + useEffect(() => { + const handler = dataURL => { + const ctx = canvasRef.current.getContext('2d'); + const img = new Image(); + img.onload = () => { + canvasRef.current.width = img.width; + canvasRef.current.height = img.height; + ctx.clearRect(0, 0, img.width, img.height); + ctx.drawImage(img, 0, 0); + }; + img.src = dataURL; + }; + + runtime.on('VISION_IMAGE', handler); + return () => runtime.off('VISION_IMAGE', handler); + }, [runtime]); + + return ( + + ); +}; + +VisionViewer.propTypes = { + runtime: PropTypes.shape({ + on: PropTypes.func.isRequired, + off: PropTypes.func.isRequired + }).isRequired +}; + +export default VisionViewer; diff --git a/packages/scratch-gui/src/playground/index.jsx b/packages/scratch-gui/src/playground/index.jsx index 6ff33f7e8b..b740bef326 100644 --- a/packages/scratch-gui/src/playground/index.jsx +++ b/packages/scratch-gui/src/playground/index.jsx @@ -1,31 +1,62 @@ -// Polyfills +// ============================ +// Polyfills para compatibilidad +// ============================ import 'es6-object-assign/auto'; import 'core-js/fn/array/includes'; import 'core-js/fn/promise/finally'; -import 'intl'; // For Safari 9 +import 'intl'; // Para Safari 9 +// ============================ +// Librerías principales +// ============================ import React from 'react'; import ReactDOM from 'react-dom'; +import VM from '../../../scratch-vm/src/index'; import AppStateHOC from '../lib/app-state-hoc.jsx'; import BrowserModalComponent from '../components/browser-modal/browser-modal.jsx'; import supportedBrowser from '../lib/supported-browser'; - import styles from './index.css'; -const appTarget = document.createElement('div'); -appTarget.className = styles.app; -document.body.appendChild(appTarget); - -if (supportedBrowser()) { - // require needed here to avoid importing unsupported browser-crashing code - // at the top level - require('./render-gui.jsx').default(appTarget); - -} else { - BrowserModalComponent.setAppElement(appTarget); - const WrappedBrowserModalComponent = AppStateHOC(BrowserModalComponent, true /* localesOnly */); - const handleBack = () => {}; - // eslint-disable-next-line react/jsx-no-bind - ReactDOM.render(, appTarget); -} +// 🧩 Decorador Vision Kit +import setupVisionKitDecorator from '../lib/vision-decorator'; + +/** + * Inicializa la aplicación GUI principal de Scratch + * y registra automáticamente las extensiones Vision Kit. + */ +const initializeApp = function () { + // === Crear contenedor base + const appTarget = document.createElement('div'); + appTarget.className = styles.app; + document.body.appendChild(appTarget); + + // === Instancia global de VM + const vm = new VM(); + window.Scratch = {vm}; + + if (supportedBrowser()) { + // === Renderizar GUI principal + const renderGUI = require('./render-gui.jsx').default; + renderGUI(appTarget, vm); + + // === Iniciar decorador Vision Kit + setupVisionKitDecorator(); // <-- 🔹 SIN pasar vm + } else { + // === Mostrar aviso de navegador no soportado + BrowserModalComponent.setAppElement(appTarget); + const WrappedBrowserModalComponent = AppStateHOC( + BrowserModalComponent, + true /* localesOnly */ + ); + + const props = {onBack: () => {}}; + ReactDOM.render( + , + appTarget + ); + } +}; + +// 🚀 Ejecutar inicialización principal +initializeApp(); diff --git a/packages/scratch-gui/src/playground/render-gui.jsx b/packages/scratch-gui/src/playground/render-gui.jsx index 04b12dd7c4..4051a2ffd7 100644 --- a/packages/scratch-gui/src/playground/render-gui.jsx +++ b/packages/scratch-gui/src/playground/render-gui.jsx @@ -8,39 +8,40 @@ import HashParserHOC from '../lib/hash-parser-hoc.jsx'; import log from '../lib/log.js'; import {PLATFORM} from '../lib/platform.js'; -const onClickLogo = () => { - window.location = 'https://scratch.mit.edu'; -}; - -const handleTelemetryModalCancel = () => { - log('User canceled telemetry modal'); -}; - -const handleTelemetryModalOptIn = () => { - log('User opted into telemetry'); -}; +import registerVisionBlocks from '../lib/vision-autoregister'; +import registerVisionExtensions from '../lib/vision-register'; +import makeToolboxXML from '../lib/make-toolbox-xml'; -const handleTelemetryModalOptOut = () => { - log('User opted out of telemetry'); -}; - -/* - * Render the GUI playground. This is a separate function because importing anything - * that instantiates the VM causes unsupported browsers to crash - * {object} appTarget - the DOM element to render to +/** + * Renderiza el GUI principal y configura Vision Kit + * @param {HTMLElement} appTarget - Elemento donde se monta el GUI + * @param {object} vm - Instancia de la máquina virtual (Scratch VM) */ -export default appTarget => { +const renderGUI = function (appTarget, vm) { GUI.setAppElement(appTarget); - // note that redux's 'compose' function is just being used as a general utility to make - // the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's - // ability to compose reducers. - const WrappedGui = compose( - AppStateHOC, - HashParserHOC - )(GUI); + // ============================== + // 🧠 Registro base de bloques visuales + // ============================== + try { + registerVisionBlocks(); + console.log('✅ [VisionKit] AutoRegistro visual activado correctamente.'); + } catch (err) { + console.warn('⚠️ Error al registrar bloques Vision:', err); + } + + const WrappedGui = compose(AppStateHOC, HashParserHOC)(GUI); + + // ============================== + // 🔗 Eventos GUI y Telemetría + // ============================== + const handleLogoClick = () => { + window.location = 'https://scratch.mit.edu'; + }; + const handleTelemetryModalCancel = () => log('User canceled telemetry modal'); + const handleTelemetryModalOptIn = () => log('User opted into telemetry'); + const handleTelemetryModalOptOut = () => log('User opted out of telemetry'); - // TODO a hack for testing the backpack, allow backpack host to be set by url param const backpackHostMatches = window.location.href.match(/[?&]backpack_host=([^&]*)&?/); const backpackHost = backpackHostMatches ? backpackHostMatches[1] : null; @@ -48,39 +49,72 @@ export default appTarget => { let simulateScratchDesktop; if (scratchDesktopMatches) { try { - // parse 'true' into `true`, 'false' into `false`, etc. simulateScratchDesktop = JSON.parse(scratchDesktopMatches[1]); } catch { - // it's not JSON so just use the string - // note that a typo like "falsy" will be treated as true simulateScratchDesktop = scratchDesktopMatches[1]; } } if (process.env.NODE_ENV === 'production' && typeof window === 'object') { - // Warn before navigating away window.onbeforeunload = () => true; } - ReactDOM.render( - // important: this is checking whether `simulateScratchDesktop` is truthy, not just defined! - simulateScratchDesktop ? - : - , - appTarget); + // ============================== + // 🖼️ Render principal del GUI + // ============================== + const guiProps = simulateScratchDesktop ? { + vm, + canEditTitle: true, + platform: PLATFORM.DESKTOP, + showTelemetryModal: true, + canSave: false, + onTelemetryModalCancel: handleTelemetryModalCancel, + onTelemetryModalOptIn: handleTelemetryModalOptIn, + onTelemetryModalOptOut: handleTelemetryModalOptOut + } : { + vm, + canEditTitle: true, + backpackVisible: true, + showComingSoon: true, + backpackHost, + canSave: false, + onClickLogo: handleLogoClick + }; + + ReactDOM.render(, appTarget); + + // ======================================================= + // 🧩 Registro manual de extensiones Vision Kit en la VM + // ======================================================= + setTimeout(async () => { + try { + const vmInstance = window.Scratch?.vm; + if (!vmInstance) { + console.warn('⚠️ VM no disponible aún.'); + return; + } + + // ✅ Registrar manualmente todas las extensiones Vision + await registerVisionExtensions(vmInstance); + + // 🕓 Esperar y refrescar toolbox dinámico + setTimeout(() => { + const primitives = Object.keys(vmInstance.runtime._primitives) + .filter(p => p.startsWith('vision')); + + console.log(`[VisionKit] Primitivos Vision detectados: ${primitives.length}`); + if (primitives.length > 0) { + const xml = makeToolboxXML(); + vmInstance.emit('workspaceUpdate', {toolboxXML: xml}); + console.log('🎨 Toolbox Vision actualizado tras registro manual.'); + } else { + console.warn('⚠️ Aún no hay primitivos Vision registrados.'); + } + }, 1000); + } catch (err) { + console.error('❌ Error en registro VisionKit:', err); + } + }, 2500); }; + +export default renderGUI; diff --git a/packages/scratch-gui/webpack.config.js b/packages/scratch-gui/webpack.config.js index 07277d0329..4cb1765f26 100644 --- a/packages/scratch-gui/webpack.config.js +++ b/packages/scratch-gui/webpack.config.js @@ -41,11 +41,18 @@ const baseConfig = new ScratchWebpackConfigBuilder( clean: false }, resolve: { + alias: { + 'scratch-vm': path.resolve(__dirname, '../scratch-vm') + }, + extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], fallback: { + fs: false, + path: false, Buffer: require.resolve('buffer/'), stream: require.resolve('stream-browserify') } } + }) .addModuleRule({ test: /\.(svg|png|wav|mp3|gif|jpg)$/, diff --git a/packages/scratch-vm/src/extension-support/extension-manager.js b/packages/scratch-vm/src/extension-support/extension-manager.js index 94ce3b1a08..2213c75a20 100644 --- a/packages/scratch-vm/src/extension-support/extension-manager.js +++ b/packages/scratch-vm/src/extension-support/extension-manager.js @@ -23,7 +23,12 @@ const builtinExtensions = { ev3: () => require('../extensions/scratch3_ev3'), makeymakey: () => require('../extensions/scratch3_makeymakey'), boost: () => require('../extensions/scratch3_boost'), - gdxfor: () => require('../extensions/scratch3_gdx_for') + gdxfor: () => require('../extensions/scratch3_gdx_for'), + + visionactions: () => require('../extensions/vision-actions'), + visionbasic: () => require('../extensions/vision-basic'), + visionintermediate: () => require('../extensions/vision-intermediate'), + visionadvanced: () => require('../extensions/vision-advanced') }; /** @@ -134,34 +139,57 @@ class ExtensionManager { this._loadedExtensions.set(extensionId, serviceName); } - /** - * Load an extension by URL or internal extension ID - * @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension - * @returns {Promise} resolved once the extension is loaded and initialized or rejected on failure - */ loadExtensionURL (extensionURL) { if (Object.prototype.hasOwnProperty.call(builtinExtensions, extensionURL)) { - /** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */ if (this.isExtensionLoaded(extensionURL)) { const message = `Rejecting attempt to load a second extension with ID ${extensionURL}`; - log.warn(message); + console.warn(message); return Promise.resolve(); } - const extension = builtinExtensions[extensionURL](); - const extensionInstance = new extension(this.runtime); - const serviceName = this._registerInternalExtension(extensionInstance); - this._loadedExtensions.set(extensionURL, serviceName); - return Promise.resolve(); - } + try { + const extensionExport = builtinExtensions[extensionURL](); + + let instance; + // 🧩 Si exporta una clase con getInfo + if ( + typeof extensionExport === 'function' && + extensionExport.prototype && + typeof extensionExport.prototype.getInfo === 'function' + ) { + instance = new extensionExport(this.runtime); + } else if (typeof extensionExport === 'function') { + // 🧩 Si exporta una función factory (que devuelve instancia) + instance = extensionExport(this.runtime); + } else if (typeof extensionExport === 'object') { + // 🧩 Si ya es un objeto con getInfo + instance = extensionExport; + } else { + console.error(`[❌ ExtensionManager] Invalid extension definition for ${extensionURL}:` + , extensionExport); + return Promise.resolve(); + } - return new Promise((resolve, reject) => { - // If we `require` this at the global level it breaks non-webpack targets, including tests - const worker = new Worker('./extension-worker.js'); + if (!instance || typeof instance.getInfo !== 'function') { + console.error(`[❌ ExtensionManager] Invalid getInfo() in ${extensionURL}`, instance); + return Promise.resolve(); + } - this.pendingExtensions.push({extensionURL, resolve, reject}); - dispatch.addWorker(worker); - }); + this._loadedExtensions.set(extensionURL, instance); + console.log(`[✅ ExtensionManager] Registered instance for ${extensionURL}`); + return Promise.resolve(); + } catch (e) { + console.error(`[❌ ExtensionManager] Error loading internal extension (${extensionURL}):`, e); + return Promise.reject(e); + } + } else { + // Caso: extensiones externas por URL (no internas) + return new Promise((resolve, reject) => { + const worker = new Worker('./extension-worker.js'); + this.pendingExtensions.push({extensionURL, resolve, reject}); + dispatch.addWorker(worker); + }); + } } /** @@ -223,18 +251,39 @@ class ExtensionManager { } } - /** - * Register an internal (non-Worker) extension object - * @param {object} extensionObject - the extension object to register - * @returns {string} The name of the registered extension service - */ - _registerInternalExtension (extensionObject) { - const extensionInfo = extensionObject.getInfo(); - const fakeWorkerId = this.nextExtensionWorker++; - const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`; - dispatch.setServiceSync(serviceName, extensionObject); - dispatch.callSync('extensions', 'registerExtensionServiceSync', serviceName); - return serviceName; + _registerInternalExtension (extensionDefinition, id) { + try { + let instance; + + // 🧩 Si exporta una clase (con getInfo en el prototype) + if ( + typeof extensionDefinition === 'function' && + extensionDefinition.prototype && + typeof extensionDefinition.prototype.getInfo === 'function' + ) { + instance = new extensionDefinition(this.runtime); + } else if (typeof extensionDefinition === 'function') { + // 🧩 Si exporta una función factory que devuelve instancia + instance = extensionDefinition(this.runtime); + } else if (typeof extensionDefinition === 'object') { + // 🧩 Si exporta ya un objeto + instance = extensionDefinition; + } else { + console.error(`[❌ ExtensionManager] Invalid extension definition for ${id}:`, extensionDefinition); + return; + } + + if (!instance || typeof instance.getInfo !== 'function') { + console.error(`[❌ ExtensionManager] ${id} did not provide getInfo().`, instance); + return; + } + + // ✅ Registrar correctamente + this._loadedExtensions.set(id, instance); + console.log(`[✅ ExtensionManager] Registered instance for ${id}`); + } catch (e) { + console.error(`[❌ ExtensionManager] Error registering internal extension (${id}):`, e); + } } /** diff --git a/packages/scratch-vm/src/extensions/vision-actions/index.js b/packages/scratch-vm/src/extensions/vision-actions/index.js new file mode 100644 index 0000000000..5ce3eaa252 --- /dev/null +++ b/packages/scratch-vm/src/extensions/vision-actions/index.js @@ -0,0 +1,166 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); + +class VisionActions { + constructor (runtime) { + this.runtime = runtime; + this.baseURL = 'http://127.0.0.1:8001'; + this.lastDataURL = null; + } + + _showAlert (message) { + const existing = document.getElementById('vision-alert'); + if (existing) existing.remove(); + + const alert = document.createElement('div'); + alert.id = 'vision-alert'; + alert.textContent = message; + Object.assign(alert.style, { + position: 'fixed', + bottom: '20px', + right: '20px', + background: '#ff6b6b', + color: 'white', + padding: '10px 20px', + borderRadius: '10px', + fontFamily: 'sans-serif', + boxShadow: '0 2px 10px rgba(0,0,0,0.2)', + zIndex: 9999, + transition: 'opacity 0.5s', + opacity: '1' + }); + + document.body.appendChild(alert); + setTimeout(() => { + alert.style.opacity = '0'; + setTimeout(() => alert.remove(), 500); + }, 3000); + } + + // ========================================================= + // ✅ INFORMACIÓN DE LA EXTENSIÓN (formato Scratch 3 oficial) + // ========================================================= + getInfo () { + return { + id: 'visionactions', + name: 'Vision Acciones', + color1: '#2DD4BF', + color2: '#0E7490', + color3: '#134E4A', + blocks: [ + { + opcode: 'visionactions_setImageURL', + blockType: BlockType.COMMAND, + text: 'cargar imagen desde URL [URL]', + arguments: { + URL: { + type: ArgumentType.STRING, + defaultValue: 'https://picsum.photos/480/360' + } + } + }, + { + opcode: 'visionactions_setImageFile', + blockType: BlockType.COMMAND, + text: 'cargar imagen desde archivo local' + }, + { + opcode: 'visionactions_show', + blockType: BlockType.COMMAND, + text: 'mostrar resultado' + }, + { + opcode: 'visionactions_exportProcessedImage', + blockType: BlockType.COMMAND, + text: 'exportar imagen procesada' + }, + { + opcode: 'visionactions_exportPythonCode', + blockType: BlockType.COMMAND, + text: 'exportar código Python' + } + ], + menus: {} // requerido aunque esté vacío + }; + } + + // ========================================================= + // ✅ REGISTRO DE PRIMITIVAS (VM las usa con el prefijo exacto) + // ========================================================= + getPrimitives () { + return { + visionactions_setImageURL: this.setImageURL.bind(this), + visionactions_setImageFile: this.setImageFile.bind(this), + visionactions_show: this.show.bind(this), + visionactions_exportProcessedImage: this.exportProcessedImage.bind(this), + visionactions_exportPythonCode: this.exportPythonCode.bind(this) + }; + } + + // ========================================================= + // 🧩 IMPLEMENTACIONES DE BLOQUES + // ========================================================= + async setImageURL (args) { + try { + const res = await fetch(args.URL); + const blob = await res.blob(); + const reader = new FileReader(); + reader.onload = () => { + this.lastDataURL = reader.result; + this.runtime.emit('VISION_IMAGE', this.lastDataURL); + }; + reader.readAsDataURL(blob); + } catch (e) { + this._showAlert('❌ Error cargando imagen desde URL.'); + } + } + + setImageFile () { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + this.lastDataURL = reader.result; + this.runtime.emit('VISION_IMAGE', this.lastDataURL); + resolve(); + }; + reader.readAsDataURL(file); + }; + input.click(); + }); + } + + show () { + if (this.lastDataURL) { + this.runtime.emit('VISION_IMAGE', this.lastDataURL); + } else { + this._showAlert('⚠️ No hay imagen cargada.'); + } + } + + exportProcessedImage () { + if (!this.lastDataURL) { + return this._showAlert('⚠️ No hay imagen procesada.'); + } + const link = document.createElement('a'); + link.href = this.lastDataURL; + link.download = 'imagen_procesada.png'; + link.click(); + } + + exportPythonCode () { + this._showAlert('El código Python se exportará desde Vision Básico / Intermedio / Avanzado.'); + } +} + +// ========================================================= +// ✅ EXPORTACIÓN FORMAL USADA POR SCRATCH VM +// ========================================================= +module.exports = function (runtime) { + return new VisionActions(runtime); +}; diff --git a/packages/scratch-vm/src/extensions/vision-advanced/index.js b/packages/scratch-vm/src/extensions/vision-advanced/index.js new file mode 100644 index 0000000000..f66f765cb5 --- /dev/null +++ b/packages/scratch-vm/src/extensions/vision-advanced/index.js @@ -0,0 +1,127 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); + +class VisionAdvanced { + constructor (runtime) { + this.runtime = runtime; + this.baseURL = 'http://127.0.0.1:8001'; + } + + // ========================================================= + // ✅ INFORMACIÓN DE LA EXTENSIÓN + // ========================================================= + getInfo () { + return { + id: 'visionadvanced', + name: 'Vision Avanzado', + color1: '#A78BFA', + color2: '#7C3AED', + color3: '#4C1D95', + blocks: [ + { + opcode: 'visionadvanced_segment', + blockType: BlockType.COMMAND, + text: 'segmentar imagen (k-means)' + }, + { + opcode: 'visionadvanced_detectFeatures', + blockType: BlockType.COMMAND, + text: 'detectar características ORB' + }, + { + opcode: 'visionadvanced_matchFeatures', + blockType: BlockType.COMMAND, + text: 'comparar características entre imágenes' + }, + { + opcode: 'visionadvanced_threshold', + blockType: BlockType.COMMAND, + text: 'aplicar umbral binario [THRESH]', + arguments: { + THRESH: { + type: ArgumentType.NUMBER, + defaultValue: 127 + } + } + }, + { + opcode: 'visionadvanced_histogram', + blockType: BlockType.COMMAND, + text: 'mostrar histograma de colores' + } + ], + menus: {} // requerido aunque no tenga menús + }; + } + + // ========================================================= + // ✅ REGISTRO DE PRIMITIVAS CON PREFIJOS CORRECTOS + // ========================================================= + getPrimitives () { + return { + visionadvanced_segment: this.segment.bind(this), + visionadvanced_detectFeatures: this.detectFeatures.bind(this), + visionadvanced_matchFeatures: this.matchFeatures.bind(this), + visionadvanced_threshold: this.threshold.bind(this), + visionadvanced_histogram: this.histogram.bind(this) + }; + } + + // ========================================================= + // 🔧 FUNCIÓN BASE DE COMUNICACIÓN CON BACKEND + // ========================================================= + async _call (op, params = {}) { + try { + const resp = await fetch(`${this.baseURL}/process`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({op, params}) + }); + + if (!resp.ok) { + console.error(`[VisionAdvanced] Error HTTP ${resp.status}`); + return; + } + + const data = await resp.json(); + + if (data.image_b64) { + this.runtime.emit('VISION_IMAGE', data.image_b64); + } else { + console.warn('[VisionAdvanced] No se recibió imagen en respuesta.'); + } + } catch (err) { + console.error('[VisionAdvanced] Error conectando con backend:', err); + } + } + + // ========================================================= + // 🧠 IMPLEMENTACIONES DE BLOQUES + // ========================================================= + segment () { + return this._call('segment'); + } + + detectFeatures () { + return this._call('detectFeatures'); + } + + matchFeatures () { + return this._call('matchFeatures'); + } + + threshold (args) { + return this._call('threshold', {thresh: args.THRESH}); + } + + histogram () { + return this._call('histogram'); + } +} + +// ========================================================= +// ✅ EXPORTACIÓN FORMAL PARA SCRATCH VM +// ========================================================= +module.exports = function (runtime) { + return new VisionAdvanced(runtime); +}; diff --git a/packages/scratch-vm/src/extensions/vision-basic/index.js b/packages/scratch-vm/src/extensions/vision-basic/index.js new file mode 100644 index 0000000000..733ecd10ae --- /dev/null +++ b/packages/scratch-vm/src/extensions/vision-basic/index.js @@ -0,0 +1,148 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); + +class VisionBasic { + constructor (runtime) { + this.runtime = runtime; + this.baseURL = 'http://127.0.0.1:8001'; + } + + // ========================================================= + // ✅ INFORMACIÓN DE LA EXTENSIÓN + // ========================================================= + getInfo () { + return { + id: 'visionbasic', + name: 'Vision Básico', + color1: '#34D399', + color2: '#059669', + color3: '#064E3B', + blocks: [ + { + opcode: 'visionbasic_brightness', + blockType: BlockType.COMMAND, + text: 'ajustar brillo [BETA]', + arguments: { + BETA: { + type: ArgumentType.NUMBER, + defaultValue: 30 + } + } + }, + { + opcode: 'visionbasic_contrast', + blockType: BlockType.COMMAND, + text: 'ajustar contraste [ALPHA]', + arguments: { + ALPHA: { + type: ArgumentType.NUMBER, + defaultValue: 1.2 + } + } + }, + { + opcode: 'visionbasic_invert', + blockType: BlockType.COMMAND, + text: 'invertir colores' + }, + { + opcode: 'visionbasic_pixelate', + blockType: BlockType.COMMAND, + text: 'pixelar imagen [F]', + arguments: { + F: { + type: ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + opcode: 'visionbasic_circles', + blockType: BlockType.COMMAND, + text: 'detectar círculos' + }, + { + opcode: 'visionbasic_rectangles', + blockType: BlockType.COMMAND, + text: 'detectar rectángulos' + } + ], + menus: {} + }; + } + + // ========================================================= + // ✅ REGISTRO DE PRIMITIVAS (IMPORTANTE) + // ========================================================= + getPrimitives () { + return { + visionbasic_brightness: this.brightness.bind(this), + visionbasic_contrast: this.contrast.bind(this), + visionbasic_invert: this.invert.bind(this), + visionbasic_pixelate: this.pixelate.bind(this), + visionbasic_circles: this.circles.bind(this), + visionbasic_rectangles: this.rectangles.bind(this) + }; + } + + // ========================================================= + // 🔧 FUNCIÓN BASE DE COMUNICACIÓN CON BACKEND + // ========================================================= + async _call (op, params = {}) { + try { + const resp = await fetch(`${this.baseURL}/process`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({op, params}) + }); + + if (!resp.ok) { + console.error(`[VisionBasic] Error HTTP ${resp.status}`); + return; + } + + const data = await resp.json(); + if (data.image_b64) { + this.runtime.emit('VISION_IMAGE', data.image_b64); + } else { + console.warn('[VisionBasic] No se recibió imagen en respuesta.'); + } + } catch (err) { + console.error('[VisionBasic] Error en conexión con backend:', err); + } + } + + // ========================================================= + // 🧩 IMPLEMENTACIONES DE BLOQUES + // ========================================================= + brightness (args) { + return this._call('brightness', {beta: args.BETA}); + } + + contrast (args) { + return this._call('contrast', {alpha: args.ALPHA}); + } + + invert () { + return this._call('invert'); + } + + pixelate (args) { + return this._call('pixelate', {factor: args.F}); + } + + circles () { + return this._call('circles'); + } + + rectangles () { + return this._call('rectangles'); + } +} + +// ========================================================= +// ✅ EXPORTACIÓN FORMAL +// ========================================================= +module.exports = function (runtime) { + return new VisionBasic(runtime); +}; diff --git a/packages/scratch-vm/src/extensions/vision-intermediate/index.js b/packages/scratch-vm/src/extensions/vision-intermediate/index.js new file mode 100644 index 0000000000..ff04a220b8 --- /dev/null +++ b/packages/scratch-vm/src/extensions/vision-intermediate/index.js @@ -0,0 +1,127 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); + +class VisionIntermediate { + constructor (runtime) { + this.runtime = runtime; + this.baseURL = 'http://127.0.0.1:8001'; + } + + // ========================================================= + // ✅ INFORMACIÓN DE LA EXTENSIÓN (con prefijos correctos) + // ========================================================= + getInfo () { + return { + id: 'visionintermediate', + name: 'Vision Intermedio', + color1: '#FACC15', + color2: '#CA8A04', + color3: '#854D0E', + blocks: [ + { + opcode: 'visionintermediate_edges', + blockType: BlockType.COMMAND, + text: 'detectar bordes (Canny)' + }, + { + opcode: 'visionintermediate_gray', + blockType: BlockType.COMMAND, + text: 'convertir a escala de grises' + }, + { + opcode: 'visionintermediate_gaussian', + blockType: BlockType.COMMAND, + text: 'aplicar filtro gaussiano' + }, + { + opcode: 'visionintermediate_rotate', + blockType: BlockType.COMMAND, + text: 'rotar imagen [ANGLE]', + arguments: { + ANGLE: {type: ArgumentType.NUMBER, defaultValue: 90} + } + }, + { + opcode: 'visionintermediate_resize', + blockType: BlockType.COMMAND, + text: 'redimensionar a [W]x[H]', + arguments: { + W: {type: ArgumentType.NUMBER, defaultValue: 320}, + H: {type: ArgumentType.NUMBER, defaultValue: 240} + } + } + ], + menus: {} // requerido por el formato Scratch + }; + } + + // ========================================================= + // ✅ REGISTRO DE PRIMITIVAS CON PREFIJO COMPLETO + // ========================================================= + getPrimitives () { + return { + visionintermediate_edges: this.edges.bind(this), + visionintermediate_gray: this.gray.bind(this), + visionintermediate_gaussian: this.gaussian.bind(this), + visionintermediate_rotate: this.rotate.bind(this), + visionintermediate_resize: this.resize.bind(this) + }; + } + + // ========================================================= + // 🔧 FUNCIÓN BASE DE COMUNICACIÓN CON BACKEND + // ========================================================= + async _call (op, params = {}) { + try { + const resp = await fetch(`${this.baseURL}/process`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({op, params}) + }); + + if (!resp.ok) { + console.error(`[VisionIntermediate] Error HTTP ${resp.status}`); + return; + } + + const data = await resp.json(); + if (data.image_b64) { + this.runtime.emit('VISION_IMAGE', data.image_b64); + } else { + console.warn('[VisionIntermediate] No se recibió imagen en respuesta.'); + } + } catch (err) { + console.error('[VisionIntermediate] Error en conexión con backend:', err); + } + } + + // ========================================================= + // 🧩 IMPLEMENTACIONES DE BLOQUES + // ========================================================= + edges () { + return this._call('edges'); + } + + gray () { + return this._call('gray'); + } + + gaussian () { + return this._call('gaussian'); + } + + rotate (args) { + return this._call('rotate', {angle: args.ANGLE}); + } + + resize (args) { + return this._call('resize', {w: args.W, h: args.H}); + } +} + +// ========================================================= +// ✅ EXPORTACIÓN FORMAL PARA SCRATCH VM +// ========================================================= +module.exports = function (runtime) { + return new VisionIntermediate(runtime); +}; diff --git a/packages/scratch-vm/src/extensions/vision/index.js b/packages/scratch-vm/src/extensions/vision/index.js new file mode 100644 index 0000000000..8eee31e1ae --- /dev/null +++ b/packages/scratch-vm/src/extensions/vision/index.js @@ -0,0 +1,549 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); + +class Vision { + constructor (runtime) { + this.runtime = runtime; + this.baseURL = 'http://127.0.0.1:8001'; + this.lastDataURL = null; + this.prevFrame = null; // para optical flow (futuro) + } + /** + * Muestra una alerta visual en la interfaz de Blockly/Scratch. + * @param {string} message - Texto a mostrar al usuario. + */ + _showAlert (message) { + const existingAlert = document.getElementById('vision-alert'); + if (existingAlert) existingAlert.remove(); + + const alert = document.createElement('div'); + alert.id = 'vision-alert'; + alert.textContent = message; + Object.assign(alert.style, { + position: 'fixed', + bottom: '20px', + right: '20px', + background: '#ff6b6b', + color: 'white', + padding: '10px 20px', + borderRadius: '10px', + fontFamily: 'sans-serif', + boxShadow: '0 2px 10px rgba(0,0,0,0.2)', + zIndex: 9999, + transition: 'opacity 0.5s', + opacity: '1' + }); + + document.body.appendChild(alert); + setTimeout(() => { + alert.style.opacity = '0'; + setTimeout(() => alert.remove(), 500); + }, 3000); + } + + /** + * Muestra un mensaje de confirmación (verde, estilo moderno). + * @param {string} message - Texto a mostrar al usuario. + */ + _showConfirm (message) { + const existing = document.getElementById('vision-confirm'); + if (existing) existing.remove(); + + const box = document.createElement('div'); + box.id = 'vision-confirm'; + box.innerHTML = `✅ ${message}`; + + Object.assign(box.style, { + position: 'fixed', + bottom: '20px', + right: '20px', + background: '#16a34a', // Verde de confirmación + color: 'white', + padding: '10px 20px', + borderRadius: '12px', + fontFamily: 'sans-serif', + boxShadow: '0 4px 12px rgba(0,0,0,0.2)', + fontSize: '15px', + zIndex: 9999, + opacity: '0', + transform: 'translateY(10px)', + transition: 'all 0.4s ease' + }); + + document.body.appendChild(box); + + // Animación de aparición + requestAnimationFrame(() => { + box.style.opacity = '1'; + box.style.transform = 'translateY(0)'; + }); + + // Desaparece después de 3 segundos + setTimeout(() => { + box.style.opacity = '0'; + box.style.transform = 'translateY(10px)'; + setTimeout(() => box.remove(), 400); + }, 3000); + } + + getInfo () { + return { + id: 'vision', + name: 'Vision Kit', + color1: '#2DD4BF', + color2: '#0E7490', + + // 🔹 Aquí definimos subcategorías (niveles) + menus: {}, + + // 🔹 Los bloques se agrupan en categorías por color + blocks: [ + + // ======== 🧩 ACCIONES ======== + { + opcode: 'setImageURL', + blockType: BlockType.COMMAND, + text: 'cargar imagen desde URL [URL]', + arguments: { + URL: {type: ArgumentType.STRING, defaultValue: 'https://picsum.photos/480/360'} + }, + color1: '#2DD4BF' + }, + { + opcode: 'setImageFile', + blockType: BlockType.COMMAND, + text: 'cargar imagen desde archivo local', + color1: '#FACC15' + }, + { + opcode: 'exportProcessedImage', + blockType: BlockType.COMMAND, + text: 'exportar imagen procesada', + color1: '#2DD4BF' + }, + { + opcode: 'show', + blockType: BlockType.COMMAND, + text: 'mostrar resultado', + color1: '#2DD4BF' + }, + + // ======== 💡 NIVEL BÁSICO ======== + { + opcode: 'brightness', + blockType: BlockType.COMMAND, + text: 'brillo [BETA]', + arguments: {BETA: {type: ArgumentType.NUMBER, defaultValue: 30}}, + color1: '#FACC15' + }, + { + opcode: 'contrast', + blockType: BlockType.COMMAND, + text: 'contraste [ALPHA]', + arguments: {ALPHA: {type: ArgumentType.NUMBER, defaultValue: 1.2}}, + color1: '#34D399' + }, + { + opcode: 'saturation', + blockType: BlockType.COMMAND, + text: 'saturación [S]', + arguments: {S: {type: ArgumentType.NUMBER, defaultValue: 1.3}}, + color1: '#34D399' + }, + { + opcode: 'invert', + blockType: BlockType.COMMAND, + text: 'invertir colores', + color1: '#34D399' + }, + { + opcode: 'pixelate', + blockType: BlockType.COMMAND, + text: 'pixelar factor [F]', + arguments: {F: {type: ArgumentType.NUMBER, defaultValue: 8}}, + color1: '#34D399' + }, + { + opcode: 'saturation', + blockType: BlockType.COMMAND, + text: 'saturación [S]', + arguments: {S: {type: ArgumentType.NUMBER, defaultValue: 1.3}}, + color1: '#34D399' + }, + { + opcode: 'circles', + blockType: BlockType.COMMAND, + text: 'detectar círculos', + categoryId: 'basico', + color1: '#34D399'}, + { + opcode: 'rectangles', + blockType: BlockType.COMMAND, + text: 'detectar rectángulos', + categoryId: 'basico', + color1: '#34D399' + }, + { + opcode: 'rectangles', + blockType: BlockType.COMMAND, + text: 'detectar rectángulos', + categoryId: 'basico', + color1: '#34D399' + }, + { + opcode: 'exportPythonCode', + blockType: BlockType.COMMAND, + text: 'exportar código Python', + categoryId: 'acciones', + color1: '#2DD4BF' + }, + // ======== ⚙️ NIVEL INTERMEDIO ======== + { + opcode: 'canny', + blockType: BlockType.COMMAND, + text: 'bordes Canny [T1] [T2]', + arguments: { + T1: {type: ArgumentType.NUMBER, defaultValue: 100}, + T2: {type: ArgumentType.NUMBER, defaultValue: 200} + }, + color1: '#FACC15' + }, + { + opcode: 'sobel', + blockType: BlockType.COMMAND, + text: 'bordes Sobel', + color1: '#FACC15' + }, + { + opcode: 'gaussian', + blockType: BlockType.COMMAND, + text: 'gaussian blur [K]', + arguments: {K: {type: ArgumentType.NUMBER, defaultValue: 5}}, + color1: '#FACC15' + }, + { + opcode: 'rotate', + blockType: BlockType.COMMAND, + text: 'rotar [DEG]', + arguments: {DEG: {type: ArgumentType.NUMBER, defaultValue: 15}}, + color1: '#FACC15' + }, + { + opcode: 'scale', + blockType: BlockType.COMMAND, + text: 'escalar [S]', + arguments: {S: {type: ArgumentType.NUMBER, defaultValue: 1.2}}, + color1: '#FACC15' + }, + + // ======== 🚀 NIVEL AVANZADO ======== + { + opcode: 'orb', + blockType: BlockType.COMMAND, + text: 'características ORB', + color1: '#A78BFA' + }, + { + opcode: 'watershed', + blockType: BlockType.COMMAND, + text: 'segmentación Watershed', + color1: '#A78BFA' + }, + { + opcode: 'kmeans', + blockType: BlockType.COMMAND, + text: 'segmentación K-means [K]', + arguments: {K: {type: ArgumentType.NUMBER, defaultValue: 3}}, + color1: '#A78BFA' + } + ] + }; + } + + setServer (args) { + this.baseURL = args.URL; + } + + async setImageURL (args) { + this.runtime._visionOps = []; + const res = await fetch(args.URL); + const blob = await res.blob(); + this.lastDataURL = await new Promise(r => { + const fr = new FileReader(); + fr.onload = () => r(fr.result); + fr.readAsDataURL(blob); + }); + this.runtime.emit('VISION_IMAGE', this.lastDataURL); + } + setImageFile () { + return new Promise(resolve => { + this.runtime._visionOps = []; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + this.lastFileName = file.name || 'imagen.jpg'; + + const reader = new FileReader(); + + reader.onload = () => { + this.lastDataURL = reader.result; + + // 🔹 Mostrar la imagen cargada localmente de inmediato + this.runtime.emit('VISION_IMAGE', this.lastDataURL); + + // 🔹 Luego intentar enviar al servidor si está activo + (async () => { + try { + const resp = await fetch(`${this.baseURL}/process`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + image_b64: this.lastDataURL, + op: 'none', // operación vacía + params: {} + }) + }); + + const data = await resp.json(); + + // Si el servidor devuelve una imagen procesada + if (data && data.image_b64) { + this.lastDataURL = data.image_b64; + this.runtime.emit('VISION_IMAGE', this.lastDataURL); + } + } catch (err) { + console.warn('⚠️ No se pudo conectar al servidor, mostrando imagen local.'); + } + + resolve(); + })(); + }; + + reader.readAsDataURL(file); + }; + + input.click(); + }); + } + async _call (op, params = {}) { + if (!this.lastDataURL) throw new Error('Primero usa "cargar imagen desde URL" o "desde archivo local".'); + + // 🧩 Registrar cada operación realizada + if (!this.runtime._visionOps) this.runtime._visionOps = []; + this.runtime._visionOps.push({name: op, params}); + + const body = {image_b64: this.lastDataURL, op, params}; + const resp = await fetch(`${this.baseURL}/process`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body) + }); + const data = await resp.json(); + if (data.image_b64) { + this.lastDataURL = data.image_b64; + this.runtime.emit('VISION_IMAGE', this.lastDataURL); + } + } + + // ----- Básico ----- + brightness (args) { + return this._call('brightness', {beta: args.BETA}); + } + contrast (args) { + return this._call('contrast', {alpha: args.ALPHA}); + } + saturation (args) { + return this._call('saturation', {s: args.S}); + } + invert () { + return this._call('invert'); + } + pixelate (args) { + return this._call('pixelate', {factor: args.F}); + } + circles () { + return this._call('circles'); + } + rectangles () { + return this._call('rectangles'); + } + + // ----- Intermedio ----- + canny (args) { + return this._call('canny', {t1: args.T1, t2: args.T2}); + } + sobel () { + return this._call('sobel'); + } + gaussian (args) { + const k = Number(args.K); + if (k < 1 || k > 99 || k % 2 === 0) { + this._showAlert(`⚠️ Tamaño de kernel inválido (${k}). Usa un número impar entre 1 y 99.`); + return; + } + return this._call('gaussian', {k}); + } + sharpen () { + return this._call('sharpen'); + } + contours () { + return this._call('contours'); + } + rotate (args) { + const deg = Number(args.DEG); + if (deg < -360 || deg > 360) { + this._showAlert(`⚠️ Ángulo inválido (${deg}). Usa valores entre -360° y 360°.`); + return; + } + return this._call('rotate', {deg}); + } + scale (args) { + const s = Number(args.S); + if (s < 0.1 || s > 5) { + this._showAlert(`⚠️ Valor de escala fuera de rango (${s}). Usa valores entre 0.1 y 5.`); + return; + } + return this._call('scale', {s}); + } + translate (args) { + return this._call('translate', {dx: args.DX, dy: args.DY}); + } + + // ----- Avanzado ----- + orb () { + return this._call('orb'); + } + watershed () { + return this._call('watershed'); + } + kmeans (args) { + return this._call('kmeans', {K: args.K}); + } + + show () { + if (this.lastDataURL) this.runtime.emit('VISION_IMAGE', this.lastDataURL); + } + + /** + * Descarga la última imagen procesada como archivo local. + */ + exportProcessedImage () { + if (!this.lastDataURL) { + this._showAlert('⚠️ No hay imagen procesada para exportar.'); + return; + } + + const link = document.createElement('a'); + link.href = this.lastDataURL; + link.download = this.lastFileName ? `procesada_${this.lastFileName.replace(/\.[^/.]+$/, '.png')}` : + 'imagen_procesada.png'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + this._showConfirm(' Imagen procesada exportada correctamente.', '#22c55e'); + } + + exportPythonCode () { + // 🔹 Traducción de operaciones a código Python + const opToPython = { + brightness: 'img = cv2.convertScaleAbs(img, alpha=1, beta={beta})', + contrast: 'img = cv2.convertScaleAbs(img, alpha={alpha}, beta=0)', + saturation: ` + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype('float32') + hsv[..., 1] *= {s} + hsv[..., 1] = np.clip(hsv[..., 1], 0, 255) + img = cv2.cvtColor(hsv.astype('uint8'), cv2.COLOR_HSV2BGR) + `.trim(), + invert: 'img = cv2.bitwise_not(img)', + pixelate: ` + h, w = img.shape[:2] + temp = cv2.resize(img, (w // {factor}, h // {factor}), interpolation=cv2.INTER_LINEAR) + img = cv2.resize(temp, (w, h), interpolation=cv2.INTER_NEAREST) + `.trim(), + canny: 'img = cv2.Canny(img, {t1}, {t2})', + sobel: ` + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + img = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=5) + `.trim(), + gaussian: 'img = cv2.GaussianBlur(img, ({k}, {k}), 0)', + sharpen: ` + kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]]) + img = cv2.filter2D(img, -1, kernel) + `.trim() + }; + + // 🔹 Obtener el nombre real del archivo o usar uno por defecto + const fileName = this.lastFileName || 'imagen.jpg'; + + // 🔹 Inicio del script Python + let pythonCode = `import cv2 + import numpy as np + + img = cv2.imread('${fileName}') + `; + + // 🔹 Lista de operaciones realizadas + const ops = this.runtime._visionOps || []; + + if (ops.length === 0) { + console.warn('⚠️ No hay operaciones para exportar.'); + return; + } + + // 🔹 Agrupar operaciones por nivel (básico / intermedio / avanzado) + const levels = { + Básico: ['brightness', 'contrast', 'saturation', 'invert', 'pixelate', 'circles', 'rectangles'], + Intermedio: ['canny', 'sobel', 'gaussian', 'sharpen', 'contours', 'rotate', 'scale', 'translate'], + Avanzado: ['orb', 'watershed', 'kmeans'] + }; + + for (const [nivel, lista] of Object.entries(levels)) { + const usadas = ops.filter(op => lista.includes(op.name)); + if (usadas.length > 0) { + pythonCode += `\n# ==== Nivel ${nivel} ====\n`; + for (const op of usadas) { + if (opToPython[op.name]) { + pythonCode += ` + ${opToPython[op.name] + .replace('{alpha}', op.params?.ALPHA || 1.2) + .replace('{beta}', op.params?.BETA || 30) + .replace('{s}', op.params?.S || 1.3) + .replace('{factor}', op.params?.F || 8) + .replace('{t1}', op.params?.T1 || 100) + .replace('{t2}', op.params?.T2 || 200) + .replace('{k}', op.params?.K || 5) +}`; + + } + } + } + } + + pythonCode += ` + + cv2.imshow('Resultado', img) + cv2.waitKey(0) + cv2.destroyAllWindows() + `; + + // 🔹 Crear archivo descargable .py + const blob = new Blob([pythonCode], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'vision_script.py'; + a.click(); + + console.log(`✅ Código Python exportado correctamente usando '${fileName}'.`); + } + + +} + + +module.exports = Vision; diff --git a/vision-backend/README.md b/vision-backend/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/vision-backend/main.py b/vision-backend/main.py new file mode 100644 index 0000000000..101efb4b18 --- /dev/null +++ b/vision-backend/main.py @@ -0,0 +1,182 @@ +from fastapi import FastAPI +from pydantic import BaseModel +import numpy as np, cv2, base64 + +app = FastAPI(title="Vision API") + +class Payload(BaseModel): + image_b64: str # "data:image/png;base64,...." o solo base64 puro + op: str # "brightness", "canny", "kmeans", etc. + params: dict | None = None + +def read_b64(data): + if data.startswith("data:"): + data = data.split(",")[1] + arr = np.frombuffer(base64.b64decode(data), dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + return img + +def to_b64(img): + ok, buf = cv2.imencode(".png", img) + assert ok + return "data:image/png;base64," + base64.b64encode(buf).decode("utf-8") + +# --------- Operaciones --------- +def op_brightness(img, beta=30, alpha=1.0): + return cv2.convertScaleAbs(img, alpha=float(alpha), beta=float(beta)) + +def op_saturation(img, s=1.3): + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype(np.float32) + hsv[:,:,1] = np.clip(hsv[:,:,1]*float(s), 0, 255) + hsv = hsv.astype(np.uint8) + return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + +def op_invert(img): + return cv2.bitwise_not(img) + +def op_pixelate(img, factor=8): + h, w = img.shape[:2] + small = cv2.resize(img, (w//factor, h//factor), interpolation=cv2.INTER_LINEAR) + return cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST) + +def op_blur(img, k=5): + k = int(k) if int(k)%2==1 else int(k)+1 + return cv2.blur(img, (k,k)) + +def op_gaussian(img, k=5): + k = int(k) if int(k)%2==1 else int(k)+1 + return cv2.GaussianBlur(img, (k,k), 0) + +def op_sharpen(img): + kernel = np.array([[0,-1,0],[-1,5,-1],[0,-1,0]]) + return cv2.filter2D(img, -1, kernel) + +def op_canny(img, t1=100, t2=200): + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + edges = cv2.Canny(gray, int(t1), int(t2)) + return cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR) + +def op_sobel(img): + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + gx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) + gy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3) + mag = cv2.convertScaleAbs(np.hypot(gx,gy)) + return cv2.cvtColor(mag, cv2.COLOR_GRAY2BGR) + +def op_contours(img): + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + _,th = cv2.threshold(gray,0,255,cv2.THRESH_OTSU+cv2.THRESH_BINARY) + cnts,_ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + vis = img.copy() + cv2.drawContours(vis, cnts, -1, (0,255,0), 2) + return vis + +def op_rotate(img, deg=15): + h,w = img.shape[:2] + M = cv2.getRotationMatrix2D((w/2,h/2), float(deg), 1.0) + return cv2.warpAffine(img, M, (w,h)) + +def op_scale(img, s=1.2): + return cv2.resize(img, None, fx=float(s), fy=float(s)) + +def op_translate(img, dx=20, dy=20): + h,w = img.shape[:2] + M = np.float32([[1,0,float(dx)],[0,1,float(dy)]]) + return cv2.warpAffine(img, M, (w,h)) + +def op_circles(img): + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + gray = cv2.medianBlur(gray, 5) + circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, 1.2, 50, + param1=100, param2=30, minRadius=20, maxRadius=0) + vis = img.copy() + if circles is not None: + for x,y,r in np.uint16(np.around(circles[0,:])): + cv2.circle(vis, (x,y), r, (0,0,255), 2) + return vis + +def op_rectangles(img): + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + _,th = cv2.threshold(gray,0,255,cv2.THRESH_OTSU+cv2.THRESH_BINARY) + cnts,_ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + vis = img.copy() + for c in cnts: + approx = cv2.approxPolyDP(c, 0.02*cv2.arcLength(c,True), True) + if len(approx)==4 and cv2.contourArea(c) > 2000: + x,y,w,h = cv2.boundingRect(approx) + cv2.rectangle(vis,(x,y),(x+w,y+h),(255,0,0),2) + return vis + +def op_orb(img): + orb = cv2.ORB_create(nfeatures=500) + kp = orb.detect(img, None) + kp, des = orb.compute(img, kp) + return cv2.drawKeypoints(img, kp, None, color=(0,255,0), flags=0) + +def op_watershed(img): + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + _,th = cv2.threshold(gray,0,255,cv2.THRESH_OTSU+cv2.THRESH_BINARY_INV) + kernel = np.ones((3,3),np.uint8) + opening = cv2.morphologyEx(th, cv2.MORPH_OPEN, kernel, iterations=2) + sure_bg = cv2.dilate(opening, kernel, iterations=3) + dist = cv2.distanceTransform(opening, cv2.DIST_L2, 5) + _, sure_fg = cv2.threshold(dist, 0.5*dist.max(), 255, 0) + sure_fg = np.uint8(sure_fg) + unknown = cv2.subtract(sure_bg, sure_fg) + _, markers = cv2.connectedComponents(sure_fg) + markers = markers + 1 + markers[unknown==255] = 0 + img2 = img.copy() + cv2.watershed(img2, markers) + img2[markers==-1] = [0,0,255] + return img2 + +def op_kmeans(img, K=3): + Z = img.reshape((-1,3)) + Z = np.float32(Z) + criteria = (cv2.TERM_CRITERIA_EPS+cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0) + _,label,center = cv2.kmeans(Z, int(K), None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) + center = np.uint8(center) + res = center[label.flatten()].reshape(img.shape) + return res + +# Optical flow requiere 2 frames: manejaremos fuera (en la extensión) el frame anterior. +# Aquí solo devolvemos la imagen que llega (para demo), o podrías dibujar flechas si recibes prev. +def op_passthrough(img): + return img + +OPS = { + # Básico + "brightness": op_brightness, + "contrast": op_brightness, # usar alpha + "saturation": op_saturation, + "invert": op_invert, + "pixelate": op_pixelate, + "blur": op_blur, + "circles": op_circles, + "rectangles": op_rectangles, + # Intermedio + "canny": op_canny, + "sobel": op_sobel, + "gaussian": op_gaussian, + "sharpen": op_sharpen, + "contours": op_contours, + "rotate": op_rotate, + "scale": op_scale, + "translate": op_translate, + # Avanzado + "orb": op_orb, + "watershed": op_watershed, + "kmeans": op_kmeans, + "flow": op_passthrough +} + +@app.post("/process") +def process(p: Payload): + img = read_b64(p.image_b64) + params = p.params or {} + f = OPS.get(p.op) + if not f: + return {"error": f"unknown op {p.op}"} + out = f(img, **{k: v for k,v in params.items() if v is not None}) + return {"image_b64": to_b64(out)} diff --git a/vision-backend/requirements.txt b/vision-backend/requirements.txt new file mode 100644 index 0000000000..338ab88422 --- /dev/null +++ b/vision-backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.4 +uvicorn[standard]==0.32.0 +opencv-python==4.10.0.84 +numpy==2.1.2 +scikit-image==0.24.0 +scikit-learn==1.5.2 +pydantic==2.9.2 diff --git a/vision-server/__pycache__/vision_server.cpython-313.pyc b/vision-server/__pycache__/vision_server.cpython-313.pyc new file mode 100644 index 0000000000..75fc82b7c1 Binary files /dev/null and b/vision-server/__pycache__/vision_server.cpython-313.pyc differ diff --git a/vision-server/requirements.txt b/vision-server/requirements.txt new file mode 100644 index 0000000000..7a24def9d2 --- /dev/null +++ b/vision-server/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +opencv-python +numpy diff --git a/vision-server/vision_server.py b/vision-server/vision_server.py new file mode 100644 index 0000000000..341e488c3b --- /dev/null +++ b/vision-server/vision_server.py @@ -0,0 +1,213 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +import cv2 +import numpy as np +import base64 +import requests + +app = FastAPI() + +# Habilitar CORS (para Scratch GUI) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # o ["http://localhost:8601"] + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +def decode_image(image_b64: str): + """Convierte base64 a imagen OpenCV (BGR).""" + img_data = base64.b64decode(image_b64.split(",")[1]) + np_arr = np.frombuffer(img_data, np.uint8) + return cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + +def encode_image(img) -> str: + """Convierte imagen OpenCV (BGR) a base64 para enviar al cliente.""" + _, buffer = cv2.imencode(".jpg", img) + return "data:image/jpeg;base64," + base64.b64encode(buffer).decode("utf-8") + +@app.post("/process") +async def process_image(request: Request): + body = await request.json() + op = body.get("op") + params = body.get("params", {}) + image_b64 = body.get("image_b64") + image_url = body.get("image_url") # 🔹 Nuevo soporte URL + + # --- Log para debug --- + print("🔹 Body recibido:", body.keys()) + + # Obtener imagen + if image_url: + print(f"📥 Descargando imagen desde URL: {image_url}") + resp = requests.get(image_url) + np_arr = np.frombuffer(resp.content, np.uint8) + img = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + elif image_b64: + print("📥 Decodificando imagen en base64") + img = decode_image(image_b64) + else: + return {"error": "No se recibió imagen (ni URL ni base64)"} + + # ----- OPERACIONES ----- + if op == "brightness": + beta = float(params.get("beta", 0)) + img = cv2.convertScaleAbs(img, alpha=1.0, beta=beta) + + elif op == "contrast": + alpha = float(params.get("alpha", 1.0)) + img = cv2.convertScaleAbs(img, alpha=alpha, beta=0) + + elif op == "saturation": + s = float(params.get("s", 1.0)) + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype("float32") + hsv[..., 1] *= s + hsv[..., 1] = np.clip(hsv[..., 1], 0, 255) + img = cv2.cvtColor(hsv.astype("uint8"), cv2.COLOR_HSV2BGR) + + elif op == "invert": + img = cv2.bitwise_not(img) + + elif op == "pixelate": + factor = int(params.get("factor", 8)) + h, w = img.shape[:2] + temp = cv2.resize(img, (w // factor, h // factor), interpolation=cv2.INTER_LINEAR) + img = cv2.resize(temp, (w, h), interpolation=cv2.INTER_NEAREST) + + elif op == "circles": + print("🔵 Detectando círculos...") + + # Convertir a escala de grises + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + gray = cv2.medianBlur(gray, 5) + + # Detectar círculos con HoughCircles + circles = cv2.HoughCircles( + gray, + cv2.HOUGH_GRADIENT, + dp=1.2, # resolución del acumulador (ajustable) + minDist=30, # distancia mínima entre centros de círculos + param1=100, # umbral superior del detector de bordes Canny + param2=30, # umbral del acumulador de centros + minRadius=10, # radio mínimo + maxRadius=200 # radio máximo + ) + + if circles is not None: + circles = np.uint16(np.around(circles)) + print(f"🔵 Se detectaron {len(circles[0])} círculos") + for i in circles[0, :]: + center = (i[0], i[1]) + radius = i[2] + # Dibuja el círculo en verde + cv2.circle(img, center, radius, (0, 255, 0), 3) + # Marca el centro con un punto rojo + cv2.circle(img, center, 3, (0, 0, 255), -1) + cv2.putText(img, "Circulo", (i[0]-20, i[1]-radius-10), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) + + elif op == "rectangles": + # Convertir a escala de grises y aplicar desenfoque + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + blurred = cv2.GaussianBlur(gray, (5, 5), 0) + + # Detectar bordes + edges = cv2.Canny(blurred, 50, 150) + + # Encontrar contornos + contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + count_rects = 0 + for cnt in contours: + approx = cv2.approxPolyDP(cnt, 0.02 * cv2.arcLength(cnt, True), True) + area = cv2.contourArea(cnt) + + # Un rectángulo tiene 4 vértices y área razonable + if len(approx) == 4 and area > 1000: + cv2.drawContours(img, [approx], 0, (0, 255, 0), 3) + count_rects += 1 + + print(f"✅ Rectángulos detectados: {count_rects}") + + elif op == "canny": + t1 = float(params.get("t1", 100)) + t2 = float(params.get("t2", 200)) + img = cv2.Canny(img, t1, t2) + img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + + elif op == "sobel": + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + grad_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) + grad_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3) + abs_grad = cv2.convertScaleAbs(cv2.addWeighted(grad_x, 0.5, grad_y, 0.5, 0)) + img = cv2.cvtColor(abs_grad, cv2.COLOR_GRAY2BGR) + + elif op == "gaussian": + k = int(params.get("k", 5)) + if k % 2 == 0: # kernel debe ser impar + k += 1 + img = cv2.GaussianBlur(img, (k, k), 0) + + elif op == "sharpen": + kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]]) + img = cv2.filter2D(img, -1, kernel) + + elif op == "rotate": + deg = float(params.get("deg", 15)) + h, w = img.shape[:2] + M = cv2.getRotationMatrix2D((w // 2, h // 2), deg, 1) + img = cv2.warpAffine(img, M, (w, h)) + + elif op == "scale": + s = float(params.get("s", 1.0)) + + # Evitar valores extremos o negativos + if not (0.1 <= s <= 5.0): + print(f"⚠️ Valor de escala inválido: {s}. Se usará 1.0 por defecto.") + s = 1.0 + + h, w = img.shape[:2] + new_w = int(w * s) + new_h = int(h * s) + + # Limitar tamaño máximo (ej. 4096x4096 píxeles) + if new_w > 4096 or new_h > 4096: + print(f"⚠️ Imagen demasiado grande ({new_w}x{new_h}), ajustando tamaño máximo.") + new_w = min(new_w, 4096) + new_h = min(new_h, 4096) + + img = cv2.resize(img, (new_w, new_h)) + + elif op == "translate": + dx = int(params.get("dx", 20)) + dy = int(params.get("dy", 20)) + M = np.float32([[1, 0, dx], [0, 1, dy]]) + h, w = img.shape[:2] + img = cv2.warpAffine(img, M, (w, h)) + + elif op == "orb": + orb = cv2.ORB_create() + kp, des = orb.detectAndCompute(img, None) + img = cv2.drawKeypoints(img, kp, None, color=(0, 255, 0)) + + elif op == "watershed": + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + img = cv2.cvtColor(thresh, cv2.COLOR_GRAY2BGR) + + elif op == "kmeans": + K = int(params.get("K", 3)) + Z = img.reshape((-1, 3)).astype(np.float32) + _, labels, centers = cv2.kmeans( + Z, K, None, + (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0), + 10, cv2.KMEANS_RANDOM_CENTERS + ) + centers = np.uint8(centers) + img = centers[labels.flatten()].reshape(img.shape) + + # ----------------------- + + result_b64 = encode_image(img) + return {"image_b64": result_b64}